@antoneeo/course-creator 0.1.0-beta.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 (34) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +25 -0
  5. package/gemini-extension.json +6 -0
  6. package/package.json +52 -0
  7. package/scripts/init.js +62 -0
  8. package/scripts/lib.js +734 -0
  9. package/scripts/postinstall.js +16 -0
  10. package/scripts/preuninstall.js +12 -0
  11. package/skills/course-creator/ENFORCEMENT.md +272 -0
  12. package/skills/course-creator/LICENSE +202 -0
  13. package/skills/course-creator/NOTICE +5 -0
  14. package/skills/course-creator/SKILL.md +95 -0
  15. package/skills/course-creator/dispatch.md +140 -0
  16. package/skills/course-creator/elicitation.md +196 -0
  17. package/skills/course-creator/feedback.md +7 -0
  18. package/skills/course-creator/guides.md +291 -0
  19. package/skills/course-creator/learning_design.md +79 -0
  20. package/skills/course-creator/memory.md +88 -0
  21. package/skills/course-creator/review.md +450 -0
  22. package/skills/course-creator/routing.md +118 -0
  23. package/skills/course-creator/scripts/course_check.py +479 -0
  24. package/skills/course-creator/scripts/entry_point.py +25 -0
  25. package/skills/course-creator/scripts/knowledge.py +2089 -0
  26. package/skills/course-creator/scripts/sdlc_check.py +73 -0
  27. package/skills/course-creator/scripts/sdlc_core.py +2707 -0
  28. package/skills/course-creator/scripts/shared_files.py +215 -0
  29. package/skills/course-creator/scripts/shared_manifest.json +27 -0
  30. package/skills/course-creator/simulation.md +47 -0
  31. package/skills/course-creator/slide_content.md +78 -0
  32. package/skills/course-creator/source_check.md +28 -0
  33. package/skills/course-creator/templates.md +222 -0
  34. package/skills/course-creator/vision.md +245 -0
@@ -0,0 +1,118 @@
1
+ # Domain Routing — which lens owns this unit of work
2
+
3
+ The family ships as sibling skills built from one shared core: each lens carries its
4
+ own **fidelity discipline** (what the work must be faithful to) and its own validation
5
+ rules, over **one** `ai_docs/` tree. This file answers one question: *which lens's
6
+ method governs THIS unit of work?* It does not decide where the resulting file is
7
+ stored (step 5), and it never runs before the triage level is known — L1 never
8
+ reaches it.
9
+
10
+ ## 0. Installed-lens detection — run first, fail open
11
+
12
+ Look in the skills directory this skill was loaded from for sibling lenses of the
13
+ family, identified by the `name:` in their `SKILL.md`:
14
+
15
+ | Lens | Skill name | Faithful to |
16
+ |---|---|---|
17
+ | code | `agentic-sdlc` | this repository's code |
18
+ | knowledge | `kb-agentic` | documents the user supplied |
19
+ | marketing | `mkt-agentic-sdlc` | market evidence |
20
+ | course | `course-creator` | a defined learner's progressive understanding |
21
+
22
+ - **No sibling installed, or the directory cannot be read** → the router does NOT run.
23
+ Work under the loaded lens; read no further. This is the normal single-lens case and
24
+ it costs nothing.
25
+ - **At least one sibling installed** → run the router below, after the level test, on
26
+ every L2, L3 and Spike.
27
+
28
+ Fail-open is deliberate: a detection that cannot answer must never block the work. A
29
+ single-lens project that silently gains a router verdict it cannot act on is worse
30
+ than no verdict at all.
31
+
32
+ ## 1. The router
33
+
34
+ Run the steps in order. Stop at the first one that decides.
35
+
36
+ 1. **Fidelity / purpose.** What must the work be faithful to?
37
+ - *This repository's code* → provisional **code**; continue to step 2.
38
+ - *Documents the user supplied* → **knowledge**. Decided.
39
+ - *Market evidence* → **marketing**. Decided.
40
+ - *A deliverable whose purpose is to teach a defined audience through progressive
41
+ explanations and modules* → **course**. Decided, regardless of whether its
42
+ sources came from a supplied corpus, web research or this repository's code.
43
+ Acquiring and checking that corpus is a separate knowledge unit. Course
44
+ content is not routed through the provisional code branch below.
45
+ - *A deliverable whose purpose is market-facing persuasion* (copy, positioning,
46
+ campaign material) → **marketing regardless of source**. Decided. Without this
47
+ branch the marketing lens is reachable only when the *source* is market evidence,
48
+ never when the *purpose* is marketing — and the launch post written from the
49
+ release notes routes to code.
50
+
51
+ 2. **Deliverable class** — only on the provisional `code` branch. The predicate is on
52
+ the deliverable, not on its audience:
53
+ - *Part of this repository's own document set, shipping and maintained with the
54
+ code* (`ai_docs/`, README, CHANGELOG, comprehension guides, migration notes) →
55
+ **code**; continue to step 3.
56
+ - *A standalone knowledge deliverable whose fidelity extends beyond this repo's
57
+ code* (user documentation for an external audience, a distilled corpus) →
58
+ **knowledge**. Decided; step 3 is not reached.
59
+
60
+ 3. **Build-consumed override** — only on the provisional `code` branch, never
61
+ overriding a *marketing*, *course* or *supplied-documents* verdict. *Is the deliverable a
62
+ file the project's build or test toolchain consumes?* Executable or imported
63
+ source: yes. Committed Markdown: no. A yes confirms **code**.
64
+
65
+ 4. **Split rule.** A request with distinct deliverables is split before routing:
66
+ corpus ingestion (knowledge) and a course based on it (course), or campaign
67
+ material (marketing) and training (course), are separate units. The same applies
68
+ to distillation (knowledge) and a technical design that cites it (code). The
69
+ router returns one lens per unit; it never returns two lenses for one unit.
70
+
71
+ 5. **Owning tree.** A straddling artifact keeps its lens for *method* and takes this
72
+ repository's tree and validator for *storage*. Lens and location are separate
73
+ answers — a marketing-lens document written in a code repository still lives in
74
+ that repository's `ai_docs/` and passes that project's validator.
75
+
76
+ 6. **Cross-check.** If the verdict contradicts an installed skill's `description`, the
77
+ descriptions are wrong: fix them. Never silently override the test with a
78
+ description.
79
+
80
+ ## 2. Worked verdicts
81
+
82
+ | Request | 1 | 2 | 3 | Lens |
83
+ |---|---|---|---|---|
84
+ | "write the API reference docs" (hand-written, ships with the repo) | code | repo's own doc set → code | not build-consumed → no flip | **code** |
85
+ | "write user documentation for our customers' admins" | code | standalone knowledge deliverable → knowledge | n/a (left the code branch at 2) | **knowledge** |
86
+ | "write the API reference docs" (generated — the work edits docstrings in source) | code | repo's own doc set → code | **build-consumed: yes → confirms code** | **code** |
87
+ | "write a comprehension guide for the auth module" | code | repo's own doc set → code | not build-consumed → no flip | **code** |
88
+ | "turn these vendor specs into a technical design" | split (step 4): spec = supplied docs; design = this repo | — | — | **knowledge** for the distillation; **code** for the design, citing it |
89
+ | "write the pricing page copy from our market research" | **purpose: market-facing → marketing** | never reached | never reached | **marketing**; stored in this repo's tree (step 5) |
90
+ | "write the launch blog post from the release notes" | purpose: market-facing → marketing | never reached | never reached | **marketing** |
91
+ | "create a beginner course from these API docs" | purpose: teaching → course | never reached | never reached | **course** |
92
+ | "research sources and teach a newcomer a topic" | purpose: teaching → course | never reached | never reached | **course** |
93
+ | "ingest these manuals, then create a course" | split: corpus = supplied docs; training = teaching | never reached | never reached | **knowledge** for ingestion; **course** for training |
94
+ | "write sales copy and an onboarding course" | split: persuasion and teaching | never reached | never reached | **marketing** for copy; **course** for training |
95
+
96
+ ## 3. Acting on the verdict
97
+
98
+ Memory is not a competing verdict. Every lens ships `memory.md` and the shared
99
+ knowledge machinery: consult across domains without switching the work's owner or
100
+ running a second workflow. Resolve an ambiguous owner before applying its definitive
101
+ risk scale; a provisional level is not a second mandatory triage. Split only distinct
102
+ deliverables, not the act of reading another domain's evidence.
103
+
104
+ The verdict binds the **method and the validation rules**, not the storage:
105
+
106
+ - **Verdict = the loaded lens** → continue; nothing changes.
107
+ - **Verdict = a sibling lens** → say so, and work that unit under the sibling's method
108
+ and its rule set (its templates, its mandatory risk section — code
109
+ `## Security and Threat Model`, knowledge `## Sources and Verification`, marketing
110
+ `## Threat Map / Plan Risks`, course `## Learning and Content Risks`). The artifact is written with an explicit
111
+ `domain:` field so the answer survives the session, and it stays in this project's
112
+ `ai_docs/` tree (step 5).
113
+ - **The unit was split** (step 4) → route and declare each half separately.
114
+
115
+ The project vision, principles and roadmap under `vision/` sit **above** the domain
116
+ split and receive the core's structural rules. A feature Vision under
117
+ `vision/features/` belongs to its explicit domain; a course Vision declares
118
+ `domain: course`. The router is not consulted for project-wide vision files.
@@ -0,0 +1,479 @@
1
+ #!/usr/bin/env python3
2
+ """Structural checks for one course; never certifies teaching quality or learning."""
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import re
6
+
7
+
8
+ REQUIRED = ("D-UC.md", "D-IC.md", "P-TM.md", "CONCEPT_GRAPH.md",
9
+ "COURSE_PLAN.md", "SIMULATION_REPORT.md")
10
+ SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*\Z")
11
+ ID = re.compile(r"\b(?:UC\d+|IC\d+|C-\d+)\b")
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Finding:
16
+ file: str
17
+ item: str
18
+ severity: str
19
+ reason: str
20
+
21
+
22
+ def _frontmatter(text):
23
+ if not text.startswith("---\n"):
24
+ return {}
25
+ end = text.find("\n---\n", 4)
26
+ if end < 0:
27
+ return {}
28
+ return dict((key.strip().lower(), value.strip())
29
+ for key, value in re.findall(r"^([\w-]+):\s*(.*)$", text[4:end], re.M))
30
+
31
+
32
+ def _table(text, required):
33
+ lines = text.splitlines()
34
+ for i, line in enumerate(lines[:-1]):
35
+ if not line.startswith("|") or not re.fullmatch(r"[\s|:-]+", lines[i + 1]):
36
+ continue
37
+ headers = [h.strip().lower() for h in line.strip("|").split("|")]
38
+ if not set(required).issubset(headers):
39
+ continue
40
+ rows = []
41
+ for row in lines[i + 2:]:
42
+ if not row.startswith("|"):
43
+ break
44
+ cells = [cell.strip() for cell in row.strip("|").split("|")]
45
+ if len(cells) != len(headers):
46
+ return None
47
+ rows.append(dict(zip(headers, cells)))
48
+ return rows
49
+ return None
50
+
51
+
52
+ def _items(cell):
53
+ return [part.strip() for part in re.split(r"[,;]", cell or "")
54
+ if part.strip() and part.strip() != "-"]
55
+
56
+
57
+ def _anchor_exists(path, anchor):
58
+ if path.suffix.lower() not in (".md", ".txt"):
59
+ if path.suffix.lower() == ".pdf":
60
+ return bool(re.fullmatch(r"page=[1-9]\d*", anchor))
61
+ if path.suffix.lower() in (".pptx", ".ppt"):
62
+ return bool(re.fullmatch(r"slide=[1-9]\d*", anchor))
63
+ return bool(re.fullmatch(r"\d{2}:\d{2}(?::\d{2})?", anchor))
64
+ text = path.read_text(encoding="utf-8", errors="replace")
65
+ return any(re.sub(r"[^a-z0-9 -]", "", heading.lower()).strip().replace(" ", "-") == anchor.lower()
66
+ for heading in re.findall(r"^#+\s+(.+)$", text, re.M))
67
+
68
+
69
+ def _content_contract(course_dir, plan, plan_rows, read, ref, add):
70
+ """Validate authored-source structure, not its pedagogical sufficiency."""
71
+ plan_path = course_dir / "COURSE_PLAN.md"
72
+
73
+ def field(text, key, path, item):
74
+ values = re.findall(r"^" + re.escape(key) + r":[ \t]*(.*)$", text, re.M)
75
+ if len(values) != 1 or not values[0].strip() or values[0].strip() == "-":
76
+ add(path, item, f"{key} must occur once with a value")
77
+ return ""
78
+ return values[0].strip()
79
+
80
+ declarations = re.findall(r"^Content contract:[ \t]*(.*)$", plan, re.M)
81
+ if not declarations:
82
+ add(plan_path, "content", "legacy course: migrate to a complete textual content contract; quality is not certified", "WARN")
83
+ return
84
+ contract = field(plan, "Content contract", plan_path, "content")
85
+ if contract not in ("slide-content-v1", "text-content-v1"):
86
+ add(plan_path, "content", "unknown content contract")
87
+ return
88
+ prefix, name = ("S", "SLIDE_CONTENT.md") if contract == "slide-content-v1" else ("U", "COURSE_CONTENT.md")
89
+ if prefix == "U":
90
+ field(plan, "Format exception", plan_path, "content")
91
+ path = course_dir / name
92
+ text = read(path, "content")
93
+ if text is None:
94
+ return
95
+ intro = text.split("\n## ", 1)[0]
96
+ if field(intro, "Course version", path, "version") != field(plan, "Course version", plan_path, "version"):
97
+ add(path, "version", "content version differs from COURSE_PLAN")
98
+ if field(intro, "Delivery mode", path, "delivery") not in ("self-study", "instructor-led"):
99
+ add(path, "delivery", "unknown delivery mode")
100
+ for key in ("Efficacy", "Feedback flow"):
101
+ if field(intro, key, path, key) != field(plan, key, plan_path, key):
102
+ add(path, key, f"{key} differs from COURSE_PLAN")
103
+ headers = ("profile", "baseline", "target capability", "teaching contribution", "observable check", "alternative and limits")
104
+ values = _table(text, headers) or []
105
+ expected_profiles = {row["profile"] for row in plan_rows}
106
+ if {row["profile"] for row in values} != expected_profiles or len(values) != len(expected_profiles):
107
+ add(path, "value", "Course Value must cover each taught profile exactly once")
108
+ for row in values:
109
+ if any(not row[h] or row[h] == "-" for h in headers):
110
+ add(path, "value", "Course Value fields must be populated; semantic review still required")
111
+ ref(row["observable check"], path, "value")
112
+ modules = {row["module id"]: row for row in plan_rows}
113
+ sections = re.split(r"(?m)^## (.+)\n", text)
114
+ units = {}
115
+ order = []
116
+ for heading, body in zip(sections[1::2], sections[2::2]):
117
+ if heading == "Course Value":
118
+ continue
119
+ if not re.fullmatch(prefix + r"[0-9]+", heading):
120
+ add(path, heading, "unexpected unit heading")
121
+ continue
122
+ if heading in units:
123
+ add(path, heading, "duplicate unit ID")
124
+ metadata = body.split("### ", 1)[0]
125
+ unit = {key: field(metadata, key, path, heading) for key in
126
+ ("Module", "Objective", "Role", "Title", "Value contribution")}
127
+ units[heading] = unit
128
+ order.append(heading)
129
+ unit["coverage"] = {(unit["Module"], unit["Objective"])}
130
+ extra_coverage = re.findall(r"^Covers:[ \t]*(.*)$", metadata, re.M)
131
+ if len(extra_coverage) > 1:
132
+ add(path, heading, "Covers must occur at most once")
133
+ if extra_coverage:
134
+ pairs = _items(extra_coverage[0])
135
+ if not pairs:
136
+ add(path, heading, "Covers must list module/objective pairs")
137
+ for pair in pairs:
138
+ if not re.fullmatch(r"M\d+/O\d+", pair):
139
+ add(path, heading, "invalid coverage pair; expected M1/O1")
140
+ continue
141
+ mid, objective = pair.split("/")
142
+ if (mid, objective) in unit["coverage"]:
143
+ add(path, heading, "duplicate coverage pair")
144
+ unit["coverage"].add((mid, objective))
145
+ for mid, objective in unit["coverage"]:
146
+ if mid not in modules or modules[mid]["objective id"] != objective:
147
+ add(path, heading, "coverage module/objective differs from COURSE_PLAN")
148
+ module = modules.get(unit["Module"])
149
+ if not module or module["objective id"] != unit["Objective"]:
150
+ add(path, heading, "unit module/objective differs from COURSE_PLAN")
151
+ if unit["Role"] not in ("orientation", "explanation", "check", "solution", "closing"):
152
+ add(path, heading, "unknown unit role")
153
+ if unit["Role"] == "check":
154
+ unit["Solution"] = field(metadata, "Solution", path, heading)
155
+ parts = re.split(r"(?m)^### (.+)\n", body)
156
+ content = list(zip(parts[1::2], parts[2::2]))
157
+ for key in ("Learner content", "Complete explanation", "Visual content", "Transition", "Sources"):
158
+ blocks = [b.strip() for k, b in content if k == key]
159
+ if len(blocks) != 1 or not blocks[0] or blocks[0] == "-":
160
+ add(path, heading, f"{key} section must occur once with content")
161
+ for key, block in content:
162
+ if key == "Sources":
163
+ for source in block.strip().splitlines():
164
+ ref(source.removeprefix("- ").strip(), path, heading, allow_url=True)
165
+ source_path = path.relative_to(course_dir.parents[3]).as_posix()
166
+ for mid, row in modules.items():
167
+ for key, roles in (("explanation", {"orientation", "explanation"}), ("check", {"check"})):
168
+ references = _items(row[key])
169
+ if not references:
170
+ continue # existing module checker reports missing fields
171
+ for value in references:
172
+ target, _, anchor = value.partition("#")
173
+ uid = anchor.upper()
174
+ unit = units.get(uid, {})
175
+ if (target != source_path or (mid, row["objective id"]) not in unit.get("coverage", set())
176
+ or unit.get("Role") not in roles):
177
+ add(plan_path, mid, f"{key} must locate its module's canonical content unit")
178
+ for uid, unit in units.items():
179
+ if unit["Role"] == "check":
180
+ solution_id = unit.get("Solution", "")
181
+ solution = units.get(solution_id, {})
182
+ if (solution.get("Role") != "solution"
183
+ or not unit["coverage"].issubset(solution.get("coverage", set()))
184
+ or order.index(solution_id) <= order.index(uid)):
185
+ add(path, uid, "check needs a subsequent solution covering every module/objective")
186
+ for row in values:
187
+ target, _, anchor = row["observable check"].partition("#")
188
+ unit = units.get(anchor.upper(), {})
189
+ covered_profiles = {modules[mid]["profile"] for mid, obj in unit.get("coverage", set()) if mid in modules}
190
+ if target != source_path or unit.get("Role") != "check" or row["profile"] not in covered_profiles:
191
+ add(path, "value", "observable check must locate a canonical check for its profile")
192
+
193
+
194
+ def validate_course(root, course_dir):
195
+ root = Path(root).resolve()
196
+ docs = root / "ai_docs"
197
+ course_dir = Path(course_dir)
198
+ findings = []
199
+
200
+ def add(path, item, reason, severity="ERROR"):
201
+ try:
202
+ name = Path(path).relative_to(root).as_posix()
203
+ except ValueError:
204
+ name = str(path)
205
+ findings.append(Finding(name, item, severity, reason))
206
+
207
+ expected_parent = docs / "solutions/courses"
208
+ if (not SLUG.fullmatch(course_dir.name) or course_dir.parent.resolve() != expected_parent.resolve()
209
+ or not course_dir.is_dir() or not course_dir.resolve().is_relative_to(root)):
210
+ add(course_dir, "course", "unsafe or missing course directory/slug")
211
+ return findings
212
+ slug = course_dir.name
213
+
214
+ def read(path, item):
215
+ path = Path(path)
216
+ try:
217
+ if not path.resolve().is_relative_to(root):
218
+ add(path, item, "unsafe path resolves outside project root")
219
+ return None
220
+ if not path.is_file():
221
+ add(path, item, f"{item} missing file")
222
+ return None
223
+ return path.read_text(encoding="utf-8")
224
+ except (OSError, UnicodeError) as exc:
225
+ add(path, item, f"cannot read file: {exc}")
226
+ return None
227
+
228
+ def ref(value, artifact, item, allow_url=False):
229
+ value = value.strip()
230
+ if allow_url and re.fullmatch(r"https?://[^\s]+", value):
231
+ return
232
+ path_part, sep, anchor = value.partition("#")
233
+ path = Path(path_part)
234
+ if (not sep or not anchor or path.is_absolute() or ".." in path.parts
235
+ or not path_part.startswith("ai_docs/")):
236
+ add(artifact, item, f"unsafe or unlocated reference '{value}'")
237
+ return
238
+ target = root / path
239
+ if not target.resolve().is_relative_to(root):
240
+ add(artifact, item, f"unsafe reference '{value}' resolves outside project root")
241
+ elif not target.is_file():
242
+ add(artifact, item, f"reference '{value}' points to missing file")
243
+ elif not _anchor_exists(target, anchor):
244
+ add(artifact, item, f"reference '{value}' has no matching locator")
245
+
246
+ vision_path = docs / f"vision/features/VISION_course_{slug}.md"
247
+ vision = read(vision_path, "Vision")
248
+ if vision is not None:
249
+ meta = _frontmatter(vision)
250
+ if meta.get("domain") != "course":
251
+ add(vision_path, "Vision", "Vision needs explicit domain: course")
252
+ if meta.get("status") != "APPROVED" or not re.search(
253
+ r"(?im)^Status:\s*APPROVED\b", vision):
254
+ add(vision_path, "Vision", "Vision must be APPROVED before course design")
255
+ analysis_path = docs / f"solutions/ANALYSIS_course_{slug}.md"
256
+ analysis = read(analysis_path, "ANALYSIS")
257
+ known = {"UC": set(), "IC": set(), "C": set()}
258
+ if analysis is not None:
259
+ meta = _frontmatter(analysis)
260
+ if meta.get("domain") != "course":
261
+ add(analysis_path, "ANALYSIS", "ANALYSIS needs explicit domain: course")
262
+ if not re.search(r"(?im)^## Learning and Content Risks\s*$", analysis):
263
+ add(analysis_path, "ANALYSIS", "Learning and Content Risks section missing")
264
+ for name, prefix in (("Use Cases / User Needs", "UC"),
265
+ ("Interface Contract", "IC"),
266
+ ("Learning and Content Risks", "C")):
267
+ match = re.search(r"(?ims)^## " + re.escape(name) + r"\s*\n(.*?)(?=^## |\Z)", analysis)
268
+ if match:
269
+ known[prefix] = {token for token in ID.findall(match.group(1))
270
+ if token.startswith(prefix + ("-" if prefix == "C" else ""))}
271
+ if not known[prefix]:
272
+ add(analysis_path, prefix, f"ANALYSIS has no defined {prefix} IDs")
273
+
274
+ material = {}
275
+ profiles = set()
276
+ for name in REQUIRED:
277
+ material[name] = read(course_dir / name, name)
278
+ for name, required, column, prefix in (
279
+ ("D-UC.md", ("uc id", "profile", "starting knowledge", "evidence", "need"), "uc id", "UC"),
280
+ ("D-IC.md", ("flow id", "surface", "conditions", "feedback channel"), "flow id", "IC"),
281
+ ("P-TM.md", ("risk id", "vector", "mitigation", "residual"), "risk id", "C")):
282
+ body = material[name]
283
+ if body is None:
284
+ continue
285
+ rows = _table(body, required)
286
+ if not rows:
287
+ add(course_dir / name, name, f"missing or empty table with {column}")
288
+ continue
289
+ seen = set()
290
+ for row in rows:
291
+ rid = row[column]
292
+ if name == "D-UC.md":
293
+ if row["profile"] and row["profile"] != "-":
294
+ profiles.add(row["profile"])
295
+ else:
296
+ add(course_dir / name, rid, "profile missing")
297
+ key = (rid, row["profile"]) if name == "D-UC.md" else rid
298
+ if key in seen:
299
+ add(course_dir / name, rid, "duplicate ID")
300
+ seen.add(key)
301
+ if rid not in known[prefix]:
302
+ add(course_dir / name, rid, f"{rid} is not defined in ANALYSIS")
303
+ for field in row:
304
+ if not row[field]:
305
+ add(course_dir / name, rid, f"{field} missing")
306
+ if name == "D-IC.md" and not row.get("feedback channel", ""):
307
+ add(course_dir / name, rid, "feedback channel or explicit unavailability missing")
308
+ for rid in known[prefix] - {row[column] for row in rows}:
309
+ add(course_dir / name, rid, f"{rid} is not detailed here")
310
+
311
+ graph_rows = _table(material["CONCEPT_GRAPH.md"] or "",
312
+ ("concept id", "prerequisites", "profile", "initial state",
313
+ "evidence", "source", "objectives"))
314
+ plan_rows = _table(material["COURSE_PLAN.md"] or "",
315
+ ("module id", "profile", "objective id", "concepts",
316
+ "explanation", "sources", "check", "next"))
317
+ if not graph_rows:
318
+ add(course_dir / "CONCEPT_GRAPH.md", "graph", "concept table missing or empty")
319
+ graph_rows = []
320
+ if not plan_rows:
321
+ add(course_dir / "COURSE_PLAN.md", "plan", "module table missing or empty")
322
+ plan_rows = []
323
+ graph = {}
324
+ prerequisites = {}
325
+ objectives = set()
326
+ for row in graph_rows:
327
+ cid = row["concept id"]
328
+ key = (cid, row["profile"])
329
+ if not re.fullmatch(r"CO\d+", cid) or key in graph:
330
+ add(course_dir / "CONCEPT_GRAPH.md", cid, "invalid or duplicate concept ID/profile")
331
+ deps = set(_items(row["prerequisites"]))
332
+ if cid in prerequisites and prerequisites[cid] != deps:
333
+ add(course_dir / "CONCEPT_GRAPH.md", cid,
334
+ "prerequisites differ between profiles for the same concept")
335
+ prerequisites.setdefault(cid, deps)
336
+ graph.setdefault(key, row)
337
+ if row["initial state"] not in ("PROVATO", "INCERTO", "DA_INSEGNARE"):
338
+ add(course_dir / "CONCEPT_GRAPH.md", cid, "invalid initial state")
339
+ if row["initial state"] == "PROVATO":
340
+ evidence = row["evidence"]
341
+ if not evidence.startswith("ai_docs/solutions/courses/" + slug + "/D-UC.md#"):
342
+ add(course_dir / "CONCEPT_GRAPH.md", cid,
343
+ "PROVATO needs precise evidence in D-UC.md")
344
+ else:
345
+ ref(evidence, course_dir / "CONCEPT_GRAPH.md", cid)
346
+ if not _items(row["source"]):
347
+ add(course_dir / "CONCEPT_GRAPH.md", cid, "source missing")
348
+ for source in _items(row["source"]):
349
+ ref(source, course_dir / "CONCEPT_GRAPH.md", cid, allow_url=True)
350
+ if not row["objectives"]:
351
+ add(course_dir / "CONCEPT_GRAPH.md", cid, "no objective")
352
+ objectives.update(_items(row["objectives"]))
353
+ if not row["profile"]:
354
+ add(course_dir / "CONCEPT_GRAPH.md", cid, "profile missing")
355
+ elif row["profile"] not in profiles:
356
+ add(course_dir / "CONCEPT_GRAPH.md", cid,
357
+ f"profile {row['profile']} not defined in D-UC")
358
+ for (cid, profile), row in graph.items():
359
+ for prereq in _items(row["prerequisites"]):
360
+ if (prereq, profile) not in graph:
361
+ add(course_dir / "CONCEPT_GRAPH.md", cid,
362
+ f"prerequisite {prereq} does not exist for profile {profile}")
363
+ visiting, done = set(), set()
364
+
365
+ def visit(key):
366
+ if key in visiting:
367
+ add(course_dir / "CONCEPT_GRAPH.md", key[0], "prerequisite cycle")
368
+ return
369
+ if key in done or key not in graph:
370
+ return
371
+ visiting.add(key)
372
+ for dep in _items(graph[key]["prerequisites"]):
373
+ visit((dep, key[1]))
374
+ visiting.remove(key)
375
+ done.add(key)
376
+
377
+ for cid in graph:
378
+ visit(cid)
379
+ first_taught = {}
380
+ module_ids = [row["module id"] for row in plan_rows]
381
+ if len(module_ids) != len(set(module_ids)) or any(
382
+ not re.fullmatch(r"M\d+", mid) for mid in module_ids):
383
+ add(course_dir / "COURSE_PLAN.md", "modules", "invalid or duplicate module ID")
384
+ for position, row in enumerate(plan_rows):
385
+ mid = row["module id"]
386
+ if row["profile"] not in profiles:
387
+ add(course_dir / "COURSE_PLAN.md", mid,
388
+ f"profile {row['profile']} not defined in D-UC")
389
+ if not _items(row["concepts"]):
390
+ add(course_dir / "COURSE_PLAN.md", mid, "concepts missing")
391
+ if row["objective id"] not in objectives:
392
+ add(course_dir / "COURSE_PLAN.md", mid, "objective not defined in concept graph")
393
+ for within, cid in enumerate(_items(row["concepts"])):
394
+ key = (cid, row["profile"])
395
+ if cid not in prerequisites:
396
+ add(course_dir / "COURSE_PLAN.md", mid, f"concept {cid} missing from graph")
397
+ elif key not in graph:
398
+ add(course_dir / "COURSE_PLAN.md", mid,
399
+ f"profile differs from concept {cid}: no state for {row['profile']}")
400
+ else:
401
+ first_taught.setdefault(key, (position, within))
402
+ if row["objective id"] not in _items(graph[key]["objectives"]):
403
+ add(course_dir / "COURSE_PLAN.md", mid,
404
+ f"objective not linked to concept {cid}")
405
+ for field in ("explanation", "sources", "check"):
406
+ values = _items(row[field])
407
+ if not values:
408
+ add(course_dir / "COURSE_PLAN.md", mid, f"{field} missing")
409
+ for value in values:
410
+ ref(value, course_dir / "COURSE_PLAN.md", mid, allow_url=field == "sources")
411
+ for field in ("profile", "objective id", "next"):
412
+ if not row[field]:
413
+ add(course_dir / "COURSE_PLAN.md", mid, f"{field} missing")
414
+ if row["next"].lower() not in ("end", "fine") and row["next"] not in module_ids:
415
+ add(course_dir / "COURSE_PLAN.md", mid, "next module does not exist")
416
+ expected_next = module_ids[position + 1] if position + 1 < len(module_ids) else "end"
417
+ if row["next"].lower() != expected_next.lower():
418
+ add(course_dir / "COURSE_PLAN.md", mid,
419
+ f"next module must follow the declared order ({expected_next})")
420
+ for key, row in graph.items():
421
+ cid, profile = key
422
+ used_at = first_taught.get(key)
423
+ if used_at is None:
424
+ if row["initial state"] != "PROVATO":
425
+ add(course_dir / "COURSE_PLAN.md", cid,
426
+ f"concept has no teaching module for profile {profile}")
427
+ continue
428
+ for dep in _items(row["prerequisites"]):
429
+ dep_key = (dep, profile)
430
+ if dep_key in graph and graph[dep_key]["initial state"] != "PROVATO":
431
+ if first_taught.get(dep_key, (len(plan_rows), 0)) >= used_at:
432
+ add(course_dir / "COURSE_PLAN.md", cid,
433
+ f"prerequisite {dep} is taught after its dependent concept for profile {profile}")
434
+ plan = material["COURSE_PLAN.md"] or ""
435
+ feedback = re.search(r"(?im)^Feedback flow:\s*(IC\d+)\s*$", plan)
436
+ if not feedback or feedback.group(1) not in known["IC"]:
437
+ add(course_dir / "COURSE_PLAN.md", "feedback", "feedback flow not defined in D-IC/ANALYSIS")
438
+ if not re.search(r"(?im)^Course version:\s*\S+", plan):
439
+ add(course_dir / "COURSE_PLAN.md", "version", "course version missing")
440
+ efficacy = re.search(r"(?im)^Efficacy:\s*(.+)$", plan)
441
+ if not efficacy:
442
+ add(course_dir / "COURSE_PLAN.md", "efficacy", "efficacy status missing")
443
+ elif efficacy.group(1).strip().lower() not in ("efficacy not verified", "efficacia non verificata"):
444
+ feedback_file = course_dir / "FEEDBACK_REPORT.md"
445
+ if not feedback_file.is_file():
446
+ add(course_dir / "COURSE_PLAN.md", "efficacy",
447
+ "verified efficacy needs real feedback; simulation alone cannot prove it")
448
+ else:
449
+ add(course_dir / "COURSE_PLAN.md", "efficacy",
450
+ "human review of efficacy evidence required", severity="WARN")
451
+ _content_contract(course_dir, plan, plan_rows, read, ref, add)
452
+ return findings
453
+
454
+
455
+ def validate_all(root):
456
+ root = Path(root).resolve()
457
+ docs = root / "ai_docs"
458
+ parent = docs / "solutions/courses"
459
+ if not parent.resolve().is_relative_to(root):
460
+ return [Finding("ai_docs/solutions/courses", "course", "ERROR",
461
+ "unsafe course directory resolves outside project root")]
462
+ slugs = {p.name for p in parent.iterdir() if p.is_dir()} if parent.is_dir() else set()
463
+ findings = []
464
+ for pattern, prefix in (("solutions/ANALYSIS_course_*.md", "ANALYSIS_course_"),
465
+ ("vision/features/VISION_course_*.md", "VISION_course_")):
466
+ for path in docs.glob(pattern):
467
+ if not path.resolve().is_relative_to(root):
468
+ findings.append(Finding(path.relative_to(root).as_posix(), prefix,
469
+ "ERROR", "unsafe course metadata resolves outside project root"))
470
+ continue
471
+ try:
472
+ meta = _frontmatter(path.read_text(encoding="utf-8"))
473
+ except (OSError, UnicodeError):
474
+ continue
475
+ if meta.get("domain") == "course":
476
+ slugs.add(path.stem.removeprefix(prefix))
477
+ for slug in sorted(slugs):
478
+ findings.extend(validate_course(root, parent / slug))
479
+ return findings
@@ -0,0 +1,25 @@
1
+ """Import the distribution's entry point, whatever it is called.
2
+
3
+ Three distributions share these batteries. `sdlc_check.py` is the code and
4
+ knowledge entry point, `mkt_check.py` the marketing one; hard-coding either name
5
+ here is how a shared test starts asserting one distribution's identity in all of
6
+ them. The core is the same file in every distribution, so what varies is only
7
+ which overlay registered its profile.
8
+ """
9
+ import importlib
10
+ import os
11
+ import sys
12
+
13
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+
15
+ _HERE = os.path.dirname(os.path.abspath(__file__))
16
+ ENTRY_POINTS = ("sdlc_check", "mkt_check")
17
+
18
+
19
+ def load():
20
+ for name in ENTRY_POINTS:
21
+ if os.path.isfile(os.path.join(_HERE, name + ".py")):
22
+ return importlib.import_module(name)
23
+ raise ImportError(
24
+ "no distribution entry point found next to the batteries "
25
+ f"(looked for: {', '.join(n + '.py' for n in ENTRY_POINTS)})")