@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
package/lib/install.js
CHANGED
|
@@ -13,8 +13,6 @@ import {
|
|
|
13
13
|
resolveAssetOverride,
|
|
14
14
|
} from "./constants.js";
|
|
15
15
|
import { installPlatformAdapters } from "./adapters.js";
|
|
16
|
-
import { injectClaudeMd } from "./claude-md.js";
|
|
17
|
-
import { injectCursorRules } from "./cursorrules.js";
|
|
18
16
|
import { ensureDir, readFileSafe, writeFileIfMissing } from "./fs-utils.js";
|
|
19
17
|
import { hasPython } from "./gates.js";
|
|
20
18
|
import { initGuardrailsMemory } from "./memory.js";
|
|
@@ -117,8 +115,6 @@ export async function install(options = {}) {
|
|
|
117
115
|
}
|
|
118
116
|
|
|
119
117
|
log("🔗 Installing platform adapters...");
|
|
120
|
-
await injectCursorRules(cwd);
|
|
121
|
-
await injectClaudeMd(cwd);
|
|
122
118
|
await installPlatformAdapters(cwd);
|
|
123
119
|
log("✅ Adapters → .cursorrules, CLAUDE.md, copilot-instructions.md, AGENTS.md, .codex/AGENTS.md");
|
|
124
120
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { GUARDRAILS_SCRIPTS_DIR } from "./constants.js";
|
|
4
|
+
import { runGuardrailsScript } from "./gates.js";
|
|
5
|
+
|
|
6
|
+
export const MEMORY_DB_PATH = ".specs/memory/memory.db";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rebuild the SQLite memory index from `.specs/` markdown artifacts.
|
|
10
|
+
*
|
|
11
|
+
* @param {{ cwd?: string }} [options]
|
|
12
|
+
* @returns {Promise<number>} exit code
|
|
13
|
+
*/
|
|
14
|
+
export async function rebuildMemoryIndex(options = {}) {
|
|
15
|
+
const cwd = options.cwd ?? process.cwd();
|
|
16
|
+
return runGuardrailsScript("memory-index", ["rebuild"], { cwd });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Query the knowledge graph for a bounded context package.
|
|
21
|
+
*
|
|
22
|
+
* @param {{ from: string, depth?: number, json?: boolean, cwd?: string }} options
|
|
23
|
+
* @returns {Promise<number>} exit code
|
|
24
|
+
*/
|
|
25
|
+
export async function queryMemory(options) {
|
|
26
|
+
const cwd = options.cwd ?? process.cwd();
|
|
27
|
+
const args = ["--from", options.from];
|
|
28
|
+
if (options.depth !== undefined) {
|
|
29
|
+
args.push("--depth", String(options.depth));
|
|
30
|
+
}
|
|
31
|
+
if (options.json) {
|
|
32
|
+
args.push("--json");
|
|
33
|
+
}
|
|
34
|
+
return runGuardrailsScript("memory-query", args, { cwd });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} [cwd]
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function memoryDbPath(cwd = process.cwd()) {
|
|
42
|
+
return path.join(cwd, MEMORY_DB_PATH);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function memoryDbRelativePath() {
|
|
49
|
+
return MEMORY_DB_PATH;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Relative scripts directory for memory tooling diagnostics.
|
|
54
|
+
*
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function memoryScriptsDir() {
|
|
58
|
+
return GUARDRAILS_SCRIPTS_DIR;
|
|
59
|
+
}
|
|
@@ -137,6 +137,39 @@ export async function cleanupWorkspaces(cwd, { featureId, taskIds, force = false
|
|
|
137
137
|
return results;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} cwd
|
|
142
|
+
* @param {string} featureId
|
|
143
|
+
* @returns {Promise<Array<{ taskId: string, path: string }>>}
|
|
144
|
+
*/
|
|
145
|
+
export async function listWorkspaces(cwd, featureId) {
|
|
146
|
+
const paths = await listWorkspacePaths(cwd, featureId);
|
|
147
|
+
return paths.map((wtPath) => ({
|
|
148
|
+
taskId: path.basename(wtPath),
|
|
149
|
+
path: wtPath,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {Awaited<ReturnType<typeof listWorkspaces>>} workspaces
|
|
155
|
+
* @param {{ json?: boolean, featureId?: string }} [options]
|
|
156
|
+
*/
|
|
157
|
+
export function formatWorkspaceList(workspaces, options = {}) {
|
|
158
|
+
if (options.json) {
|
|
159
|
+
return JSON.stringify({ feature: options.featureId, workspaces }, null, 2);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const lines = ["Workspace list:"];
|
|
163
|
+
if (!workspaces.length) {
|
|
164
|
+
lines.push(" (none)");
|
|
165
|
+
} else {
|
|
166
|
+
for (const item of workspaces) {
|
|
167
|
+
lines.push(` ${item.taskId}: ${item.path}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return `${lines.join("\n")}\n`;
|
|
171
|
+
}
|
|
172
|
+
|
|
140
173
|
/**
|
|
141
174
|
* @param {ReturnType<typeof prepareWorkspaces> extends Promise<infer T> ? T : never} results
|
|
142
175
|
* @param {{ json?: boolean }} [options]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luizsantiago/spec-guardrails",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Keep AI coding agents honest — specify the work, prove each step, verify independently. Process mode (Node) for flexibility; Brakes mode (Node + Python) for structural gates and a Guarantees matrix. Progressive loading, independent verify — any AI agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"guardrails": "node index.js",
|
|
14
14
|
"test": "npm run test:node && npm run test:gates",
|
|
15
|
-
"test:node": "node --test test/install.test.js test/test_feature_init.test.js test/test_config.test.js test/test_archive.test.js test/test_delta_merge.test.js test/test_presets.test.js test/test_brownfield.test.js test/test_doctor.test.js test/test_token_cost.test.js test/test_next_steps.test.js test/test_classify_change.test.js test/test_feature_status.test.js test/test_agent_contract.test.js test/test_gates_python.test.js test/test_specs_utils.test.js test/test_validation_verdict.test.js test/test_execution_policy.test.js test/test_workspace_isolation.test.js",
|
|
15
|
+
"test:node": "node --test test/install.test.js test/test_feature_init.test.js test/test_config.test.js test/test_archive.test.js test/test_delta_merge.test.js test/test_presets.test.js test/test_brownfield.test.js test/test_doctor.test.js test/test_token_cost.test.js test/test_next_steps.test.js test/test_classify_change.test.js test/test_feature_status.test.js test/test_agent_contract.test.js test/test_gates_python.test.js test/test_specs_utils.test.js test/test_validation_verdict.test.js test/test_execution_policy.test.js test/test_workspace_isolation.test.js test/test_adapter_registry.test.js",
|
|
16
16
|
"test:gates": "node test/run-gate-tests.mjs",
|
|
17
17
|
"prepublishOnly": "npm test"
|
|
18
18
|
},
|
|
@@ -47,8 +47,10 @@ Gates live in `.specs/guardrails/scripts/` (Python 3). **The agent runs them** a
|
|
|
47
47
|
| `feature-init "…"` | Optional — or ask the agent to `/specify` |
|
|
48
48
|
| `project-init` | Optional — brownfield repo with existing code |
|
|
49
49
|
| `doctor` | Install looks broken |
|
|
50
|
+
| `workspace-list <feature>` | Inspect parallel worktrees before cleanup |
|
|
51
|
+
| `execution-policy status` | Check budgets and scope before expanding Execute |
|
|
50
52
|
|
|
51
|
-
Everything else (`validate-spec`, `validate-tasks`, `check-commit`, …) is normally run **by the agent**, not memorized from this file. Full list: `npx @luizsantiago/spec-guardrails --help` or `.specs/GETTING_STARTED.md`.
|
|
53
|
+
Everything else (`validate-spec`, `validate-tasks`, `check-commit`, `workspace-prepare`, `execution-policy check-path`, …) is normally run **by the agent**, not memorized from this file. Full list: `npx @luizsantiago/spec-guardrails --help` or `.specs/GETTING_STARTED.md`.
|
|
52
54
|
|
|
53
55
|
Non-zero gate exit = STOP. Without Python, the agent performs the same checks manually.
|
|
54
56
|
|
|
@@ -46,6 +46,15 @@ STATE_FEATURE = re.compile(
|
|
|
46
46
|
r"^\s*-\s*Feature:\s*(.+)$",
|
|
47
47
|
re.IGNORECASE | re.MULTILINE,
|
|
48
48
|
)
|
|
49
|
+
TASK_HEADING = re.compile(
|
|
50
|
+
r"^#{2,6}\s*(?P<id>T\d{1,6})\s*[:\-–]?\s*(?P<title>.*)$",
|
|
51
|
+
re.MULTILINE | re.IGNORECASE,
|
|
52
|
+
)
|
|
53
|
+
VERIFY_HINT = re.compile(
|
|
54
|
+
r"\b(test strategy|verification|validate\.md|/verify|acceptance test)\b",
|
|
55
|
+
re.IGNORECASE,
|
|
56
|
+
)
|
|
57
|
+
MEDIUM_TASK_FLOOR = 5
|
|
49
58
|
|
|
50
59
|
|
|
51
60
|
def git_branch(root: Path) -> str | None:
|
|
@@ -121,6 +130,20 @@ def build_report(feature_dir: Path, root: Path) -> Report:
|
|
|
121
130
|
else:
|
|
122
131
|
report.warn("tasks.md missing or empty — task coverage checks skipped")
|
|
123
132
|
|
|
133
|
+
task_count = len(TASK_HEADING.findall(tasks_text or ""))
|
|
134
|
+
medium_plus = task_count >= MEDIUM_TASK_FLOOR
|
|
135
|
+
|
|
136
|
+
if medium_plus and design_text is None:
|
|
137
|
+
report.warn(
|
|
138
|
+
f"{task_count} tasks — design.md recommended for Medium+ features"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
plan_text = "\n".join(filter(None, [spec_text, design_text]))
|
|
142
|
+
if medium_plus and plan_text and not VERIFY_HINT.search(plan_text):
|
|
143
|
+
report.warn(
|
|
144
|
+
"no verification or test strategy mention in spec/design — add verify plan before Execute"
|
|
145
|
+
)
|
|
146
|
+
|
|
124
147
|
if design_text is not None and tasks_text:
|
|
125
148
|
visible_design = visible_markdown(design_text).strip()
|
|
126
149
|
if len(visible_design.splitlines()) < 5:
|
package/scripts/lessons.py
CHANGED
|
@@ -40,7 +40,18 @@ FEATURE_IN_PATH = re.compile(
|
|
|
40
40
|
r"(?:^|/)features/(?P<name>[^/]+)/", re.IGNORECASE
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
-
STATUSES = (
|
|
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
|
-
|
|
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 **
|
|
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
|
|
232
|
+
if not guidance:
|
|
187
233
|
lines.append("- none yet")
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
lines.append(
|
|
199
|
-
|
|
200
|
-
|
|
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"] = "
|
|
327
|
+
existing["status"] = "approved"
|
|
278
328
|
save_store(store)
|
|
279
329
|
return ok(
|
|
280
|
-
f"{existing['id']} promoted to
|
|
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 "
|
|
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 = [
|
|
369
|
+
rows = [
|
|
370
|
+
item for item in store["lessons"] if normalize_status(item.get("status")) == wanted
|
|
371
|
+
]
|
|
314
372
|
else:
|
|
315
|
-
return fail(
|
|
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")
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
420
|
-
listing.add_argument("--status", default="
|
|
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)
|