@antoneeo/course-creator 0.1.0-beta.14 → 0.1.0-beta.16
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/CHANGELOG.md +68 -0
- package/README.md +24 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/scripts/init.js +62 -62
- package/scripts/lib.js +98 -98
- package/scripts/postinstall.js +16 -16
- package/scripts/preuninstall.js +12 -12
- package/skills/course-creator/SKILL.md +4 -3
- package/skills/course-creator/learning_design.md +1 -1
- package/skills/course-creator/review.md +28 -0
- package/skills/course-creator/scripts/course_check.py +288 -132
- package/skills/course-creator/scripts/sdlc_core.py +54 -14
- package/skills/course-creator/scripts/shared_files.py +1 -0
- package/skills/course-creator/scripts/shared_manifest.json +38 -37
- package/skills/course-creator/slide_content.md +2 -2
- package/skills/course-creator/templates.md +62 -1
- package/skills/course-creator/vision.md +308 -308
|
@@ -198,6 +198,34 @@ is a belief: the row would say a gate ran and never whether it could do its job,
|
|
|
198
198
|
the correlation with `revise_rounds` and `findings_real` — the only evidence the
|
|
199
199
|
policy pays — could not be drawn.
|
|
200
200
|
|
|
201
|
+
**The owner approves the design before the work starts (L3).** The reviewer judges
|
|
202
|
+
whether the design holds and serves the Vision; only the owner can say whether it is
|
|
203
|
+
what they meant beyond what the Vision wrote, and an intent error costs least before
|
|
204
|
+
the work exists. So the final design PASS — final once any scoped re-review it owed
|
|
205
|
+
has passed (§Review-driven corrections) — does not start the work: the agent
|
|
206
|
+
presents the design and asks the owner whether to proceed, in the form
|
|
207
|
+
`elicitation.md` §The form of a question gives a reserved approval. The presentation
|
|
208
|
+
is a short summary — what the change does (the Functional Spec, or the lens's
|
|
209
|
+
equivalent), for whom and through which surfaces (use cases, contract), what it
|
|
210
|
+
touches (Impact), the threats and their mitigations, the review's verdict with any
|
|
211
|
+
open findings — and the path of the full design. At the round cap, the hand-over the
|
|
212
|
+
cap prescribes, followed by this one question, takes the place of the form, and the
|
|
213
|
+
owner's answer settles both. An approval is recorded verbatim in the request record
|
|
214
|
+
under a label that begins `design approved`; a no or a correction takes its own
|
|
215
|
+
label, and the design goes back — where the revision changes what the review judged,
|
|
216
|
+
moment 1 runs again on that part first (the delta rule above). Only after the
|
|
217
|
+
approval does the ANALYSIS go `PLANNED` → `IN_PROGRESS` and the work start: code,
|
|
218
|
+
drafting or production, by lens. An instruction given before the design was shown
|
|
219
|
+
("carry on", "do the whole unit") does not approve it. Where a lens's own phases
|
|
220
|
+
already have the owner approve the design — the marketing lens's strategy USER GATE
|
|
221
|
+
— that gate is this approval, never a second question, and the lens's own unattended
|
|
222
|
+
path applies. Otherwise, unattended, the unit is `BLOCKED on the user`
|
|
223
|
+
(`elicitation.md` Unattended path), never approved by assumption; a late arrival
|
|
224
|
+
(moment 1b) asks before any further work. Hybrid: the owner's approval of the
|
|
225
|
+
`E-ISP`/`E-TDD` proposal in devPNT, under devPNT's own authorization rules, is this
|
|
226
|
+
approval. In Standalone, `validate` notes an L3 ANALYSIS in implementation, started
|
|
227
|
+
on or after 2026-10-31, whose request record holds no `design approved` entry.
|
|
228
|
+
|
|
201
229
|
**One schema for both modes**, meaning one required CORE plus each mode's own
|
|
202
230
|
**mode-specific** realization columns. The core is what both modes actually share: `date`, `doc_key`,
|
|
203
231
|
`tier`, `model`, `findings_raised`, `findings_real`, `verdict`, `revise_rounds`.
|
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
or
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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"
|
|
@@ -100,6 +100,10 @@ CHRONOLOGY_EPOCH = "2026-10-18"
|
|
|
100
100
|
# filled '## Guide check'. The day AFTER the rule was written, so the analyses
|
|
101
101
|
# opened that day by parallel units (which never saw the template) stay quiet.
|
|
102
102
|
GUIDE_CHECK_EPOCH = "2026-10-24"
|
|
103
|
+
# Owner design approval (F-087): an L3 started on/after this date owes a request-
|
|
104
|
+
# record entry labelled `design approved` before implementation. The day AFTER the
|
|
105
|
+
# rule was written, like GUIDE_CHECK_EPOCH.
|
|
106
|
+
DESIGN_APPROVAL_EPOCH = "2026-10-31"
|
|
103
107
|
NEXT_MAX_CHARS = 240 # this repository's longest `next:` was 214 at the epoch
|
|
104
108
|
ID_SCAN_MAX_REFS = 300 # A3 is skipped above it (one advisory says so, T9)
|
|
105
109
|
ID_SCAN_REFS_PER_CALL = 50 # keeps one git command line far below 32k chars
|
|
@@ -1479,17 +1483,50 @@ def id_advisories(root):
|
|
|
1479
1483
|
return out
|
|
1480
1484
|
|
|
1481
1485
|
|
|
1482
|
-
def
|
|
1483
|
-
"""True when an L3 ANALYSIS
|
|
1484
|
-
|
|
1485
|
-
shipped. PLANNED is exempt -- the review is due at the END of Phase 3, so an
|
|
1486
|
-
analysis still being drafted is not late."""
|
|
1486
|
+
def _l3_in_work_since(meta, epoch):
|
|
1487
|
+
"""True when an L3 ANALYSIS is in implementation or done and began on/after
|
|
1488
|
+
`epoch` (an ISO date string)."""
|
|
1487
1489
|
if meta.get("level", "").upper() != "L3":
|
|
1488
1490
|
return False
|
|
1489
1491
|
if meta.get("status") not in ("IN_PROGRESS", "COMPLETED"):
|
|
1490
1492
|
return False
|
|
1491
1493
|
started = parse_iso((meta.get("start_date") or "").strip().strip("'\""))
|
|
1492
|
-
return started is not None and started >= parse_iso(
|
|
1494
|
+
return started is not None and started >= parse_iso(epoch)
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def design_review_due(meta):
|
|
1498
|
+
"""True when an L3 ANALYSIS owes a design-review row (review.md moment 1):
|
|
1499
|
+
implementation has started or finished, and it began on/after the gate
|
|
1500
|
+
shipped. PLANNED is exempt -- the review is due at the END of Phase 3, so an
|
|
1501
|
+
analysis still being drafted is not late."""
|
|
1502
|
+
return _l3_in_work_since(meta, DESIGN_REVIEW_EPOCH)
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def design_approval_due(meta):
|
|
1506
|
+
"""True when an L3 ANALYSIS owes the owner's design approval (review.md
|
|
1507
|
+
section When a review is due): in implementation or done, begun on/after
|
|
1508
|
+
DESIGN_APPROVAL_EPOCH. PLANNED is exempt -- the question comes after the PASS."""
|
|
1509
|
+
return _l3_in_work_since(meta, DESIGN_APPROVAL_EPOCH)
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def requests_naming(root, analysis_name):
|
|
1513
|
+
"""The request records whose `analysis:` field names this ANALYSIS file."""
|
|
1514
|
+
req = ai_path(root, "audit", "requests")
|
|
1515
|
+
if not req.is_dir():
|
|
1516
|
+
return []
|
|
1517
|
+
return [r for r in sorted(req.glob("REQUEST_*.md"))
|
|
1518
|
+
if str(load_frontmatter(read_text(r).splitlines()).get("analysis") or "")
|
|
1519
|
+
.strip().strip("'\"`") == analysis_name]
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def design_approval_logged(root, analysis_name):
|
|
1523
|
+
"""True when a request record naming this ANALYSIS has an entry whose LABEL
|
|
1524
|
+
begins `design approved` (case-insensitive). Only the label is read, never
|
|
1525
|
+
the quoted words: a quotation is data, and a 'design approved?' inside a
|
|
1526
|
+
refusal approves nothing."""
|
|
1527
|
+
return any(label.lower().startswith("design approved")
|
|
1528
|
+
for r in requests_naming(root, analysis_name)
|
|
1529
|
+
for _, _, label, _ in request_entries(read_text(r))[0])
|
|
1493
1530
|
|
|
1494
1531
|
|
|
1495
1532
|
def review_logged(root, analysis_name):
|
|
@@ -1801,6 +1838,12 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1801
1838
|
advisories.append(f"{rel}: L3 in implementation with no design-review row in "
|
|
1802
1839
|
f"{review_log_rel()} -- the design was reviewed by nobody but its "
|
|
1803
1840
|
"author before code was written (review.md moment 1)")
|
|
1841
|
+
if (not hybrid and design_approval_due(meta)
|
|
1842
|
+
and not design_approval_logged(root, p.name)):
|
|
1843
|
+
advisories.append(f"{rel}: L3 in implementation with no design approval in its "
|
|
1844
|
+
"request record (no entry whose label begins 'design approved') -- the "
|
|
1845
|
+
"owner never said the design was what they meant before the "
|
|
1846
|
+
"work started (review.md, When a review is due)")
|
|
1804
1847
|
if ledger_due(meta) and not has_ledger_heading(text):
|
|
1805
1848
|
advisories.append(f"{rel}: L3 without '## Capability Ledger' -- the architect pass "
|
|
1806
1849
|
"left no record (architect.md); run it before the Impact")
|
|
@@ -2562,7 +2605,8 @@ def cmd_gate(args):
|
|
|
2562
2605
|
sys.stderr.write(
|
|
2563
2606
|
f"[sdlc gate] '{rel}' is on a protected path but no ANALYSIS_*.md is IN_PROGRESS. "
|
|
2564
2607
|
"If your analysis already exists, set its frontmatter to 'status: IN_PROGRESS' "
|
|
2565
|
-
"(that flip is what opens the gate
|
|
2608
|
+
"(that flip is what opens the gate; at L3, only after the owner's design approval, "
|
|
2609
|
+
"review.md); otherwise write it first (Phase 3).\n")
|
|
2566
2610
|
return 2
|
|
2567
2611
|
|
|
2568
2612
|
|
|
@@ -3063,13 +3107,9 @@ def _unit_documents(root, p):
|
|
|
3063
3107
|
reading or writing any of them is working on the unit."""
|
|
3064
3108
|
slug = p.stem[len("ANALYSIS_"):]
|
|
3065
3109
|
docs = {p, ai_path(root, "audit", f"HANDOFF_{slug}.md")}
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
meta = load_frontmatter(read_text(r).splitlines())
|
|
3070
|
-
if str(meta.get("analysis") or "").strip().strip("'\"`") == p.name:
|
|
3071
|
-
docs.add(r)
|
|
3072
|
-
docs.add(ai_path(root, "audit", f"HANDOFF_{r.stem[len('REQUEST_'):]}.md"))
|
|
3110
|
+
for r in requests_naming(root, p.name):
|
|
3111
|
+
docs.add(r)
|
|
3112
|
+
docs.add(ai_path(root, "audit", f"HANDOFF_{r.stem[len('REQUEST_'):]}.md"))
|
|
3073
3113
|
return {_norm_rel(d.resolve().as_posix()) for d in docs}
|
|
3074
3114
|
|
|
3075
3115
|
|
|
@@ -57,6 +57,7 @@ SHARED_FILES = (
|
|
|
57
57
|
"scripts/test_merge_safety.py",
|
|
58
58
|
"scripts/test_audit_refs.py",
|
|
59
59
|
"scripts/test_constraints.py",
|
|
60
|
+
"scripts/test_design_approval.py",
|
|
60
61
|
# the docs site (F-072): generator, its battery and its page
|
|
61
62
|
"scripts/docs_site.py",
|
|
62
63
|
"scripts/test_docs_site.py",
|