@antoneeo/course-creator 0.1.0-beta.13 → 0.1.0-beta.15

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.
@@ -12,7 +12,7 @@
12
12
  var D = null; // documents, loaded on first use
13
13
  var K = null; // knowledge base, loaded on first use
14
14
  var app = document.getElementById("app");
15
- var ORIGINS = ["given", "elicited", "derived", "ruling", "imported"];
15
+ var ORIGINS = ["given", "elicited", "derived", "ruling", "imported", "declared"];
16
16
 
17
17
  // ------------------------------------------------------------- helpers
18
18
 
@@ -483,6 +483,7 @@
483
483
  if (f.statement) marks.push(el("span", { "class": "tag statement", title: t("statement_notice"), text: t("statement_mark") }));
484
484
  if (f.state === "superseded") marks.push(el("span", { "class": "tag replaced", title: t("state_superseded_why"), text: t("state_superseded") }));
485
485
  if (f.state === "contested") marks.push(el("span", { "class": "tag conflict", title: t("state_contested_why"), text: t("state_contested") }));
486
+ if (f.state === "diverges") marks.push(el("span", { "class": "tag conflict", title: t("state_diverges_why"), text: t("state_diverges") }));
486
487
  if (opts && opts.topic) marks.push(link("#/topic/" + encodeURIComponent(f.topic), f.topic, "chip"));
487
488
  return el("li", { "class": "fact" + (f.state === "superseded" ? " replaced" : ""), onclick: function () { location.hash = "#/fact/" + encodeURIComponent(id); } },
488
489
  el("span", { "class": "dot bg-" + oc }),
@@ -597,6 +598,11 @@
597
598
  el("p", { "class": "why-line", text: t("state_contested_why") }),
598
599
  el("ul", { "class": "facts" }, (f.with || []).map(function (s) { return factLine(s, { topic: true }); })));
599
600
  }
601
+ if (f.state === "diverges") {
602
+ parts.push(el("h3", null, el("span", { "class": "tag conflict", text: t("state_diverges") }), " ", t("fact_diverges_from")),
603
+ el("p", { "class": "why-line", text: t("state_diverges_why") }),
604
+ el("ul", { "class": "facts" }, (f.diverges_from || []).map(function (s) { return factLine(s, { topic: true }); })));
605
+ }
600
606
  if (f.grounds) {
601
607
  parts.push(el("h3", { text: t("fact_rests_on") }),
602
608
  el("p", { "class": "why-line", text: t("ground_class_" + f.ground_class) }),
@@ -56,7 +56,9 @@ window.DOCS_SITE_STRINGS = {
56
56
  event_request: "request record",
57
57
  event_note: "note",
58
58
  event_source: "source document received",
59
+ event_sent: "document sent",
59
60
  event_review: "review",
61
+ event_defect: "defect reported after merge",
60
62
  event_decision: "decision record",
61
63
  event_project_note: "project note",
62
64
  event_spike: "exploration outcome",
@@ -231,6 +233,8 @@ window.DOCS_SITE_STRINGS = {
231
233
  origin_ruling_why: "Taken to settle a conflict.",
232
234
  origin_imported: "from another knowledge base",
233
235
  origin_imported_why: "Brought in from another knowledge base, where its own sources are.",
236
+ origin_declared: "declared by this project",
237
+ origin_declared_why: "What this project told a recipient, on the day it was sent: a statement of ours, not a fact about the product.",
234
238
  origin_other: "origin as written: {v}",
235
239
  origin_other_why: "An origin this site does not know.",
236
240
 
@@ -239,6 +243,8 @@ window.DOCS_SITE_STRINGS = {
239
243
  state_superseded_why: "A newer fact takes its place.",
240
244
  state_contested: "in conflict",
241
245
  state_contested_why: "Facts that disagree, not yet settled.",
246
+ state_diverges: "diverges",
247
+ state_diverges_why: "What we declared contradicts what the project decided. It is corrected by a new send, or the decision changes.",
242
248
 
243
249
  statement_mark: "statement",
244
250
  statement_prefix: "[statement] {text}",
@@ -254,6 +260,7 @@ window.DOCS_SITE_STRINGS = {
254
260
  fact_replaced_by: "Replaced by",
255
261
  fact_current_at_end: "Current fact at the end of each chain",
256
262
  fact_in_conflict_with: "In conflict with",
263
+ fact_diverges_from: "Diverges from",
257
264
  fact_rests_on: "What it rests on",
258
265
  fact_untraced: "This part could not be traced.",
259
266
  fact_sources: "Sources",
@@ -266,6 +273,7 @@ window.DOCS_SITE_STRINGS = {
266
273
  fact_unknown: "This fact is not in this knowledge base.",
267
274
 
268
275
  ground_document: "a document",
276
+ ground_declared: "a document this project sent",
269
277
  ground_statement: "a person's statement",
270
278
  ground_decision: "a decision",
271
279
  ground_kb: "another knowledge base",
@@ -273,6 +281,7 @@ window.DOCS_SITE_STRINGS = {
273
281
  ground_class_statement: "Rests, at least in part, on a person's statement.",
274
282
  ground_class_untraceable: "Part of what it rests on could not be traced.",
275
283
  ground_class_imported: "Rests on another knowledge base.",
284
+ ground_class_declared: "Rests on our own declaration to a recipient, not on evidence.",
276
285
  ground_class_documents: "Rests on documents or decisions only.",
277
286
 
278
287
  chain_loop: "The chain of replacements loops back to {id}: it stops here.",
@@ -18,6 +18,7 @@
18
18
  --o-derived: #a99be0;
19
19
  --o-ruling: #7fa7e6;
20
20
  --o-imported: #9cc96b;
21
+ --o-declared: #d98fb0;
21
22
  --conflict: #e0605a;
22
23
  --display: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Charter, Georgia, serif;
23
24
  --body: "Segoe UI Variable Text", "Avenir Next", Avenir, "Gill Sans Nova", "Gill Sans", "Trebuchet MS", sans-serif;
@@ -179,6 +180,7 @@ a.factid code { color: var(--link); }
179
180
  .o-derived { color: var(--o-derived); } .bg-derived { background: var(--o-derived); }
180
181
  .o-ruling { color: var(--o-ruling); } .bg-ruling { background: var(--o-ruling); }
181
182
  .o-imported { color: var(--o-imported); } .bg-imported { background: var(--o-imported); }
183
+ .o-declared { color: var(--o-declared); } .bg-declared { background: var(--o-declared); }
182
184
  .o-other { color: var(--paper-3); } .bg-other { background: var(--paper-3); }
183
185
 
184
186
  .panel {
@@ -262,6 +262,12 @@ in the snapshot, never to your assumption about what the code does.
262
262
  (source-anchored), not an elaborated version of it.
263
263
  - When in doubt about whether something is "supported" by the source, treat it
264
264
  as not covered rather than stretching the marker to fit.
265
+ - **Declared exception: `## Preservation constraints`.** A code guide may carry
266
+ this section, copied at an L3 closure from the ANALYSIS's table (code lens,
267
+ `SKILL.md` Phase 5). The section's marker cites each row it holds as
268
+ `[source: solutions/ANALYSIS_<name>.md#P<n>]` instead of a snapshot anchor: each
269
+ row records what the code must keep doing, as that unit enumerated and probed it,
270
+ not an excerpt of the code.
265
271
 
266
272
  ## 4. Ingestion bound (T9)
267
273
 
@@ -293,5 +299,7 @@ state it explicitly when handing off a newly created guide.
293
299
  closure (docs travel with the code); when you merely READ one, check it against
294
300
  the code before trusting it. Too large to absorb → mark it `SUPERSEDED` rather
295
301
  than leave a confident-wrong map badged CURRENT, which is worse than none.
302
+ - **A regenerated guide keeps `## Preservation constraints` unchanged**: the
303
+ section does not come from the snapshot (§3), so regeneration carries it over.
296
304
  - **Agent-global KB guides** use the same pipeline and validator via
297
305
  `--root ~/.agentic-sdlc`; freshness via the same `stale` engine.
@@ -40,7 +40,7 @@ When the procedure is performed by a tool or agent, the worked case shows the to
40
40
 
41
41
  Introduce necessary terms before using them; keep related data, diagram labels and explanations together. Remove decorative or irrelevant information. Segment a complex explanation into meaningful steps while retaining how they connect. Tailor support to the learner rather than equating more words with better teaching.
42
42
 
43
- The complete source can be detailed without making every slide dense. In self-study, all essential reasoning must remain visible across the learner's units; in guided delivery, author the necessary spoken explanation. If content does not fit, divide the canonical units before rendering instead of shrinking text or hiding meaning in notes. Preserve exact examples and visual relationships through the handoff.
43
+ The complete source can be detailed without making every slide dense. In self-study, all essential reasoning must remain visible across the learner's units; in guided delivery, author the necessary spoken explanation, and give the instructor the operating instructions and materials in `INSTRUCTOR_GUIDE.md` (`templates.md`). If content does not fit, divide the canonical units before rendering instead of shrinking text or hiding meaning in notes. Preserve exact examples and visual relationships through the handoff.
44
44
 
45
45
  ## 6. Use recall, practice and feedback to improve the explanation
46
46
 
@@ -187,7 +187,10 @@ can block forever gets removed. **Log one row per completed review, PASS or FAIL
187
187
  logging only passes would erase exactly that evidence. The row goes in
188
188
  `ai_docs/audit/reviews/REVIEW_LOG.md` (create it if absent — `templates.md`):
189
189
  `| date | doc_key | tier | model | reviewer | findings_raised | findings_real | verdict | revise_rounds |`,
190
- with `tier` = `design` or `closure` in Standalone. **`model` records the capability
190
+ with `tier` = `design` or `closure` in Standalone. In the code lens, a wrong behaviour
191
+ the owner reports in a merged L3 change is one row too, tier `post-merge`, `model`
192
+ `none (owner report)`, reviewer `owner`, the owner quoted in the notes: a defect
193
+ record, not a review, so `benefit` keeps it out of every review figure. **`model` records the capability
191
194
  tier that actually ran** — `deep`, `light`, `economy`, `single (client exposes no
192
195
  choice)`, `below floor: <reason>`, or `none (not due)` on a NOT DUE row — never a provider name in doctrine (a project
193
196
  may of course write its own model names in its own log). Without it the floor above
@@ -285,6 +288,11 @@ The binding inputs, by review type:
285
288
  clause reads both), and the **probe harness** (`ai_docs/solutions/harness_[feature]/`).
286
289
  Hand these *in addition to* the design artifact — the reviewer checks the artifact
287
290
  **against** them, not only for internal consistency.
291
+ - **The constraint view comes last.** Where the ANALYSIS carries ids, the reviewer
292
+ derives the callers and behaviours first, then compares with
293
+ `sdlc_check.py constraints --unit <id>`: a constraint the design states and the
294
+ diff breaks is a finding. The view never replaces the ANALYSIS or the diff in the
295
+ request.
288
296
  - **For a closure review, the diff and the test evidence**: the run output that
289
297
  backs every completion claim, dated after the final relevant change.
290
298
  - **For a design or closure review of an L3/E3 unit, the request record**
@@ -2,6 +2,7 @@
2
2
  """Structural checks for one course; never certifies teaching quality or learning."""
3
3
  from dataclasses import dataclass
4
4
  from pathlib import Path
5
+ import os
5
6
  import re
6
7
 
7
8
 
@@ -54,7 +55,7 @@ def _items(cell):
54
55
  if part.strip() and part.strip() != "-"]
55
56
 
56
57
 
57
- def _anchor_exists(path, anchor):
58
+ def _anchor_exists(path, anchor):
58
59
  if path.suffix.lower() not in (".md", ".txt"):
59
60
  if path.suffix.lower() == ".pdf":
60
61
  return bool(re.fullmatch(r"page=[1-9]\d*", anchor))
@@ -63,132 +64,284 @@ def _anchor_exists(path, anchor):
63
64
  return bool(re.fullmatch(r"\d{2}:\d{2}(?::\d{2})?", anchor))
64
65
  text = path.read_text(encoding="utf-8", errors="replace")
65
66
  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")
67
+ for heading in re.findall(r"^#+\s+(.+)$", text, re.M))
68
+
69
+
70
+ def _content_contract(course_dir, plan, plan_rows, read, ref, add):
71
+ """Validate authored-source structure, not its pedagogical sufficiency."""
72
+ plan_path = course_dir / "COURSE_PLAN.md"
73
+
74
+ def field(text, key, path, item):
75
+ values = re.findall(r"^" + re.escape(key) + r":[ \t]*(.*)$", text, re.M)
76
+ if len(values) != 1 or not values[0].strip() or values[0].strip() == "-":
77
+ add(path, item, f"{key} must occur once with a value")
78
+ return ""
79
+ return values[0].strip()
80
+
81
+ declarations = re.findall(r"^Content contract:[ \t]*(.*)$", plan, re.M)
82
+ if not declarations:
83
+ add(plan_path, "content", "legacy course: migrate to a complete textual content contract; quality is not certified", "WARN")
84
+ return
85
+ contract = field(plan, "Content contract", plan_path, "content")
86
+ if contract not in ("slide-content-v1", "text-content-v1"):
87
+ add(plan_path, "content", "unknown content contract")
88
+ return
89
+ prefix, name = ("S", "SLIDE_CONTENT.md") if contract == "slide-content-v1" else ("U", "COURSE_CONTENT.md")
90
+ if prefix == "U":
91
+ field(plan, "Format exception", plan_path, "content")
92
+ path = course_dir / name
93
+ text = read(path, "content")
94
+ if text is None:
95
+ return
96
+ intro = text.split("\n## ", 1)[0]
97
+ if field(intro, "Course version", path, "version") != field(plan, "Course version", plan_path, "version"):
98
+ add(path, "version", "content version differs from COURSE_PLAN")
99
+ mode = field(intro, "Delivery mode", path, "delivery")
100
+ if mode not in ("self-study", "instructor-led"):
101
+ add(path, "delivery", "unknown delivery mode")
102
+ for key in ("Efficacy", "Feedback flow"):
103
+ if field(intro, key, path, key) != field(plan, key, plan_path, key):
104
+ add(path, key, f"{key} differs from COURSE_PLAN")
105
+ headers = ("profile", "baseline", "target capability", "teaching contribution", "observable check", "alternative and limits")
106
+ values = _table(text, headers) or []
107
+ expected_profiles = {row["profile"] for row in plan_rows}
108
+ if {row["profile"] for row in values} != expected_profiles or len(values) != len(expected_profiles):
109
+ add(path, "value", "Course Value must cover each taught profile exactly once")
110
+ for row in values:
111
+ if any(not row[h] or row[h] == "-" for h in headers):
112
+ add(path, "value", "Course Value fields must be populated; semantic review still required")
113
+ ref(row["observable check"], path, "value")
114
+ modules = {row["module id"]: row for row in plan_rows}
115
+ sections = re.split(r"(?m)^## (.+)\n", text)
116
+ units = {}
117
+ order = []
118
+ for heading, body in zip(sections[1::2], sections[2::2]):
119
+ if heading == "Course Value":
120
+ continue
121
+ if not re.fullmatch(prefix + r"[0-9]+", heading):
122
+ add(path, heading, "unexpected unit heading")
123
+ continue
124
+ if heading in units:
125
+ add(path, heading, "duplicate unit ID")
126
+ metadata = body.split("### ", 1)[0]
127
+ unit = {key: field(metadata, key, path, heading) for key in
128
+ ("Module", "Objective", "Role", "Title", "Value contribution")}
129
+ units[heading] = unit
130
+ order.append(heading)
131
+ unit["coverage"] = {(unit["Module"], unit["Objective"])}
132
+ extra_coverage = re.findall(r"^Covers:[ \t]*(.*)$", metadata, re.M)
133
+ if len(extra_coverage) > 1:
134
+ add(path, heading, "Covers must occur at most once")
135
+ if extra_coverage:
136
+ pairs = _items(extra_coverage[0])
137
+ if not pairs:
138
+ add(path, heading, "Covers must list module/objective pairs")
139
+ for pair in pairs:
140
+ if not re.fullmatch(r"M\d+/O\d+", pair):
141
+ add(path, heading, "invalid coverage pair; expected M1/O1")
142
+ continue
143
+ mid, objective = pair.split("/")
144
+ if (mid, objective) in unit["coverage"]:
145
+ add(path, heading, "duplicate coverage pair")
146
+ unit["coverage"].add((mid, objective))
147
+ for mid, objective in unit["coverage"]:
148
+ if mid not in modules or modules[mid]["objective id"] != objective:
149
+ add(path, heading, "coverage module/objective differs from COURSE_PLAN")
150
+ module = modules.get(unit["Module"])
151
+ if not module or module["objective id"] != unit["Objective"]:
152
+ add(path, heading, "unit module/objective differs from COURSE_PLAN")
153
+ if unit["Role"] not in ("orientation", "explanation", "check", "solution", "closing"):
154
+ add(path, heading, "unknown unit role")
155
+ if unit["Role"] == "check":
156
+ unit["Solution"] = field(metadata, "Solution", path, heading)
157
+ parts = re.split(r"(?m)^### (.+)\n", body)
158
+ content = list(zip(parts[1::2], parts[2::2]))
159
+ for key in ("Learner content", "Complete explanation", "Visual content", "Transition", "Sources"):
160
+ blocks = [b.strip() for k, b in content if k == key]
161
+ if len(blocks) != 1 or not blocks[0] or blocks[0] == "-":
162
+ add(path, heading, f"{key} section must occur once with content")
163
+ for key, block in content:
164
+ if key == "Sources":
165
+ for source in block.strip().splitlines():
166
+ ref(source.removeprefix("- ").strip(), path, heading, allow_url=True)
167
+ source_path = path.relative_to(course_dir.parents[3]).as_posix()
168
+ for mid, row in modules.items():
169
+ for key, roles in (("explanation", {"orientation", "explanation"}), ("check", {"check"})):
170
+ references = _items(row[key])
171
+ if not references:
172
+ continue # existing module checker reports missing fields
173
+ for value in references:
174
+ target, _, anchor = value.partition("#")
175
+ uid = anchor.upper()
176
+ unit = units.get(uid, {})
177
+ if (target != source_path or (mid, row["objective id"]) not in unit.get("coverage", set())
178
+ or unit.get("Role") not in roles):
179
+ add(plan_path, mid, f"{key} must locate its module's canonical content unit")
180
+ for uid, unit in units.items():
181
+ if unit["Role"] == "check":
182
+ solution_id = unit.get("Solution", "")
183
+ solution = units.get(solution_id, {})
184
+ if (solution.get("Role") != "solution"
185
+ or not unit["coverage"].issubset(solution.get("coverage", set()))
186
+ or order.index(solution_id) <= order.index(uid)):
187
+ add(path, uid, "check needs a subsequent solution covering every module/objective")
188
+ for row in values:
189
+ target, _, anchor = row["observable check"].partition("#")
190
+ unit = units.get(anchor.upper(), {})
191
+ covered_profiles = {modules[mid]["profile"] for mid, obj in unit.get("coverage", set()) if mid in modules}
192
+ if target != source_path or unit.get("Role") != "check" or row["profile"] not in covered_profiles:
193
+ add(path, "value", "observable check must locate a canonical check for its profile")
194
+ title = re.search(r"^# (.+)$", text, re.M)
195
+ return {"path": path, "text": text, "mode": mode, "title": title.group(1).strip() if title else "",
196
+ "solutions": [uid for uid in dict.fromkeys(order) if units[uid]["Role"] == "solution"]}
197
+
198
+
199
+ GUIDE_SECTIONS = ("Before the course", "Schedule", "Reserved material", "When something deviates",
200
+ "After the course", "Materials")
201
+ VERSION_MENTION = re.compile(r"\bversion[e]?\s*:?\s*v?(\d+\.[A-Za-z0-9][A-Za-z0-9.-]*)", re.I)
202
+ COURSE_WORD = re.compile(r"\b(?:corso|course)\b", re.I)
203
+
204
+
205
+ def _version_mentions(path, body, version, names, add):
206
+ """Flag a line that names the course and mentions a version other than Course version."""
207
+ heading = "text"
208
+ for line in body.splitlines():
209
+ if line.startswith("## "):
210
+ heading = line[3:].strip()
211
+ continue
212
+ if not (COURSE_WORD.search(line) or any(
213
+ re.search(r"(?<!\w)" + re.escape(name) + r"(?!\w)", line, re.I) for name in names if name)):
214
+ continue
215
+ for match in VERSION_MENTION.finditer(line):
216
+ found = match.group(1).rstrip(".")
217
+ if found != version:
218
+ add(path, heading, f"mentions course version {found}; Course version is {version}")
219
+
220
+
221
+ def _delivery_package(root, course_dir, plan, plan_rows, contract, risks, read, add):
222
+ """Validate the instructor-led delivery package; structure only, never the quality of the instructions."""
223
+ version = (re.findall(r"^Course version:[ \t]*(.*)$", plan, re.M) or [""])[0].strip()
224
+ names = (course_dir.name, contract["title"])
225
+ if version:
226
+ _, _, rest = contract["text"].partition("\n## ")
227
+ _version_mentions(contract["path"], "## " + rest if rest else "", version, names, add)
228
+ guide_path = course_dir / "INSTRUCTOR_GUIDE.md"
229
+ if not guide_path.is_file():
230
+ if contract["mode"] == "instructor-led":
231
+ add(guide_path, "instructor guide", "instructor-led course needs INSTRUCTOR_GUIDE.md before delivery")
232
+ return
233
+ guide = read(guide_path, "instructor guide")
234
+ if guide is None:
235
+ return
236
+ intro, _, rest = guide.partition("\n## ")
237
+ body = "## " + rest if rest else ""
238
+ for key in ("Course version", "Feedback flow"):
239
+ values = re.findall(r"^" + re.escape(key) + r":[ \t]*(.*)$", intro, re.M)
240
+ expected = re.findall(r"^" + re.escape(key) + r":[ \t]*(.*)$", plan, re.M)
241
+ if len(values) != 1 or not expected or values[0].strip() != expected[0].strip():
242
+ add(guide_path, key, f"{key} must occur once and equal COURSE_PLAN")
243
+ if version:
244
+ _version_mentions(guide_path, body, version, names, add)
245
+ parts = re.split(r"(?m)^## (.+)\n", body)
246
+ sections = {}
247
+ for heading, block in zip(parts[1::2], parts[2::2]):
248
+ sections.setdefault(heading.strip(), []).append(block.strip())
249
+ for name in GUIDE_SECTIONS:
250
+ blocks = sections.get(name, [])
251
+ if len(blocks) != 1 or not blocks[0]:
252
+ add(guide_path, name, f"section '## {name}' must occur once with content")
253
+
254
+ def section(name):
255
+ return (sections.get(name) or [""])[0]
256
+
257
+ course_root = course_dir.resolve()
258
+ docs_root = (root / "ai_docs").resolve()
259
+
260
+ def packaged(value, item, locator_allowed):
261
+ """Resolve a guide path inside the project; report and return None when it is not usable."""
262
+ path_part, sep, anchor = value.partition("#")
263
+ path = Path(path_part)
264
+ if (not path.parts or path.is_absolute() or path.drive or path.root or ".." in path.parts
265
+ or (sep and (not locator_allowed or not anchor))):
266
+ add(guide_path, item, f"'{value}' must be a path relative to the project root, with no drive, "
267
+ "leading '/' or '..'" + ("" if locator_allowed else ", and no '#locator'"))
268
+ return None
269
+ target = (root / path).resolve()
270
+ if not target.is_relative_to(root):
271
+ add(guide_path, item, f"'{value}' resolves outside the project, so it cannot be delivered")
272
+ elif not target.exists():
273
+ add(guide_path, item, f"'{value}' does not exist: create it, or correct the path")
274
+ elif not locator_allowed and path_part.endswith("/") != target.is_dir():
275
+ add(guide_path, item, f"'{value}' must end with '/' exactly when it is a folder")
276
+ elif anchor and (not target.is_file() or not _anchor_exists(target, anchor)):
277
+ add(guide_path, item, f"'{value}' has no matching locator")
278
+ else:
279
+ return target
280
+ return None
281
+
282
+ def nested_repository(target):
283
+ """Read folder names only; never follow a symlink or a Windows junction out of the folder."""
284
+ for base, dirs, _ in os.walk(target):
285
+ if ".git" in dirs or "ai_docs" in dirs or Path(base, ".git").is_file():
286
+ return True
287
+ dirs[:] = [d for d in dirs if not (os.path.islink(os.path.join(base, d))
288
+ or getattr(os.path, "isjunction", lambda _: False)(
289
+ os.path.join(base, d)))]
290
+ return False
291
+
292
+ rows = _table(section("Materials"), ("material", "path", "seen by", "used in")) or []
293
+ if not rows:
294
+ add(guide_path, "Materials", "Materials table must list the delivered package")
295
+ targets = []
296
+ for row in rows:
297
+ target = packaged(row["path"], "Materials", False)
298
+ if target is not None:
299
+ targets.append(target)
300
+ if target.is_dir() and target.is_relative_to(docs_root) and nested_repository(target):
301
+ add(guide_path, "Materials", f"'{row['path']}' holds its own repository (.git or ai_docs/) "
302
+ "inside ai_docs/, so project memory indexes its files as this project's: "
303
+ "keep it outside ai_docs/", "WARN")
304
+ if row["seen by"] not in ("instructor", "learners"):
305
+ add(guide_path, "Materials", f"'{row['path']}' Seen by must be instructor or learners")
306
+ listed = {row["path"] for row in rows}
307
+ materials = {value.rstrip("/") for value in listed}
308
+ cited = {value.rstrip(".,;:") for value in re.findall(r"(?<![\w/.])ai_docs/[^\s|`<>\"'()*]+", guide)}
309
+ for value in sorted(cited - listed):
310
+ target = packaged(value, "citation", True)
311
+ if target is not None and not any(target == t or target.is_relative_to(t)
312
+ for t in (course_root, *targets)):
313
+ add(guide_path, "citation", f"'{value}' is cited but is not in the delivered package: "
314
+ "list it in '## Materials' or move it into the course folder")
315
+ reserved = section("Reserved material")
316
+ for uid in contract["solutions"]:
317
+ if not re.search(r"\b" + re.escape(uid) + r"\b", reserved):
318
+ add(guide_path, uid, f"solution unit {uid} must be named in '## Reserved material'")
319
+ rows = _table(section("When something deviates"),
320
+ ("risk id", "in class it looks like", "instructor action", "fallback material")) or []
321
+ if not rows:
322
+ add(guide_path, "When something deviates", "deviation table must have at least one row")
323
+ for row in rows:
324
+ rid = row["risk id"] or "deviation"
325
+ if row["risk id"] not in risks:
326
+ add(guide_path, rid, f"risk {rid} is not defined in P-TM.md")
327
+ fallback = row["fallback material"]
328
+ if not fallback:
329
+ add(guide_path, rid, "fallback material must be '-' or paths listed in '## Materials'")
330
+ elif fallback != "-":
331
+ for value in (part.strip() for part in fallback.split(",") if part.strip()):
332
+ if value.rstrip("/") not in materials:
333
+ add(guide_path, rid, f"fallback '{value}' is not listed in '## Materials'")
334
+ modules = {row["module id"] for row in plan_rows}
335
+ rows = _table(section("Schedule"), ("session", "duration", "modules")) or []
336
+ if not rows:
337
+ add(guide_path, "Schedule", "schedule table must have at least one session")
338
+ for row in rows:
339
+ if not row["session"] or not row["duration"] or not (_items(row["modules"]) or row["modules"] == "-"):
340
+ add(guide_path, "Schedule", "each schedule row needs a name, a duration and its modules, "
341
+ "or '-' for a break or closing")
342
+ for mid in _items(row["modules"]):
343
+ if mid not in modules:
344
+ add(guide_path, mid, f"schedule module {mid} is not in COURSE_PLAN")
192
345
 
193
346
 
194
347
  def validate_course(root, course_dir):
@@ -448,11 +601,14 @@ def validate_course(root, course_dir):
448
601
  else:
449
602
  add(course_dir / "COURSE_PLAN.md", "efficacy",
450
603
  "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):
604
+ contract = _content_contract(course_dir, plan, plan_rows, read, ref, add)
605
+ if contract:
606
+ risks = {row["risk id"] for row in _table(material["P-TM.md"] or "", ("risk id",)) or []}
607
+ _delivery_package(root, course_dir, plan, plan_rows, contract, risks, read, add)
608
+ return findings
609
+
610
+
611
+ def validate_all(root):
456
612
  root = Path(root).resolve()
457
613
  docs = root / "ai_docs"
458
614
  parent = docs / "solutions/courses"