@antoneeo/course-creator 0.1.0-beta.3 → 0.1.0-beta.4

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.
@@ -54,7 +54,7 @@ def _items(cell):
54
54
  if part.strip() and part.strip() != "-"]
55
55
 
56
56
 
57
- def _anchor_exists(path, anchor):
57
+ def _anchor_exists(path, anchor):
58
58
  if path.suffix.lower() not in (".md", ".txt"):
59
59
  if path.suffix.lower() == ".pdf":
60
60
  return bool(re.fullmatch(r"page=[1-9]\d*", anchor))
@@ -63,132 +63,132 @@ def _anchor_exists(path, anchor):
63
63
  return bool(re.fullmatch(r"\d{2}:\d{2}(?::\d{2})?", anchor))
64
64
  text = path.read_text(encoding="utf-8", errors="replace")
65
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")
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
192
 
193
193
 
194
194
  def validate_course(root, course_dir):
@@ -448,11 +448,11 @@ def validate_course(root, course_dir):
448
448
  else:
449
449
  add(course_dir / "COURSE_PLAN.md", "efficacy",
450
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):
451
+ _content_contract(course_dir, plan, plan_rows, read, ref, add)
452
+ return findings
453
+
454
+
455
+ def validate_all(root):
456
456
  root = Path(root).resolve()
457
457
  docs = root / "ai_docs"
458
458
  parent = docs / "solutions/courses"
@@ -336,6 +336,17 @@ def kb_check_claims(root):
336
336
  if rel not in fm_cache:
337
337
  fm_cache[rel] = _note_frontmatter(root, rel)
338
338
  return fm_cache[rel]
339
+ # F-069: the same per-run memo for the source paths themselves -- each
340
+ # distinct path is resolved and stat'ed once, and each cited extraction
341
+ # read once (loc_cache, owned here, dropped when this call returns).
342
+ src_cache = {}
343
+ loc_cache = {}
344
+
345
+ def _resolved(path_s):
346
+ if path_s not in src_cache:
347
+ t = sdlc_core.confine_under(root, path_s)
348
+ src_cache[path_s] = (t, t is not None and t.is_file())
349
+ return src_cache[path_s]
339
350
  per_file_rows = []
340
351
  for p in sorted(topics.glob("*.md")):
341
352
  rel = "topics/" + p.name
@@ -357,16 +368,16 @@ def kb_check_claims(root):
357
368
  errors.append("%s: source %r has no locator" % (where, src))
358
369
  continue
359
370
  path_s, loc = src.rsplit("#", 1)
360
- target = sdlc_core.confine_under(root, path_s)
371
+ target, is_file = _resolved(path_s)
361
372
  if target is None:
362
373
  errors.append("%s: source %r escapes the docs root" % (where, src))
363
374
  continue
364
- if not target.is_file():
375
+ if not is_file:
365
376
  errors.append("%s: source %r does not resolve — a claim whose "
366
377
  "origin cannot be reopened is model knowledge"
367
378
  % (where, src))
368
379
  continue
369
- kb_check_locator(target, loc, where, errors)
380
+ kb_check_locator(target, loc, where, errors, loc_cache)
370
381
  # --- provenance ---
371
382
  prov = row["prov"]
372
383
  # Resolved ONCE, for every provenance: the non-GIVEN classes read it
@@ -556,8 +567,14 @@ def kb_check_claims(root):
556
567
  return errors, warnings, notes
557
568
 
558
569
 
559
- def kb_check_locator(target, loc, where, errors):
560
- """A locator must address an existing span of the stored bytes."""
570
+ def kb_check_locator(target, loc, where, errors, cache=None):
571
+ """A locator must address an existing span of the stored bytes.
572
+
573
+ `cache` is an optional dict the CALLER owns for one run (F-069): keyed by
574
+ the path actually read, it holds only what the checks compare against --
575
+ page lengths, a line count, or False for a missing extraction -- never the
576
+ text. Findings are still appended per call, so per row. Without it, every
577
+ call reads the file, as before."""
561
578
  m = LOC_PAGE_RE.match(loc)
562
579
  if m:
563
580
  page, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
@@ -566,17 +583,24 @@ def kb_check_locator(target, loc, where, errors):
566
583
  return
567
584
  # offsets address the stored extraction (<stem>.txt beside the original)
568
585
  ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
569
- if not ext.is_file():
586
+ key = ("p", ext)
587
+ if cache is None or key not in cache:
588
+ lengths = ([len(pg) for pg in sdlc_core.read_text(ext).split("\f")]
589
+ if ext.is_file() else False)
590
+ if cache is not None:
591
+ cache[key] = lengths
592
+ else:
593
+ lengths = cache[key]
594
+ if lengths is False:
570
595
  errors.append("%s: no stored extraction %s for locator %r — offsets "
571
596
  "must address kept bytes" % (where, ext.name, loc))
572
597
  return
573
- pages = sdlc_core.read_text(ext).split("\f")
574
- if page < 1 or page > len(pages):
598
+ if page < 1 or page > len(lengths):
575
599
  errors.append("%s: locator %r addresses page %d of %d" %
576
- (where, loc, page, len(pages)))
577
- elif b > len(pages[page - 1]):
600
+ (where, loc, page, len(lengths)))
601
+ elif b > lengths[page - 1]:
578
602
  errors.append("%s: locator %r spans past the end of page %d "
579
- "(%d chars)" % (where, loc, page, len(pages[page - 1])))
603
+ "(%d chars)" % (where, loc, page, lengths[page - 1]))
580
604
  return
581
605
  m = LOC_LINE_RE.match(loc)
582
606
  if m:
@@ -584,7 +608,13 @@ def kb_check_locator(target, loc, where, errors):
584
608
  if a > b or a < 1:
585
609
  errors.append("%s: locator %r has an invalid line range" % (where, loc))
586
610
  return
587
- n = sdlc_core.read_text(target).count("\n") + 1
611
+ key = ("L", target)
612
+ if cache is None or key not in cache:
613
+ n = sdlc_core.read_text(target).count("\n") + 1
614
+ if cache is not None:
615
+ cache[key] = n
616
+ else:
617
+ n = cache[key]
588
618
  if b > n:
589
619
  errors.append("%s: locator %r spans past line %d" % (where, loc, n))
590
620
  return
@@ -1085,12 +1115,18 @@ KB_GENERATED_RE = re.compile(r"GENERATED by \S+ index - do not edit by hand")
1085
1115
  KB_HEX12_RE = re.compile(r"^[0-9a-f]{12}$")
1086
1116
 
1087
1117
 
1088
- def _kb_hex_bounded(text, cid):
1089
- """True when cid appears NOT embedded in a longer hex run: a git hash
1090
- sharing a substring must never count as a citation (either side, either
1091
- case). cid is pre-validated 12-hex, so it is regex-safe by construction."""
1092
- return re.search(r"(?<![0-9a-fA-F])%s(?![0-9a-fA-F])" % cid,
1093
- text) is not None
1118
+ # A citation of a claim id is a run of exactly its 12 lowercase hex characters
1119
+ # NOT embedded in a longer hex run: a git hash sharing a substring must never
1120
+ # count as a citation (either side, either case). Two such bounded runs cannot
1121
+ # overlap, so findall returns every one of them.
1122
+ KB_HEX_TOKEN_RE = re.compile(r"(?<![0-9a-fA-F])[0-9a-f]{12}(?![0-9a-fA-F])")
1123
+
1124
+
1125
+ def _kb_hex_tokens(text):
1126
+ """The ids a text cites, extracted in one pass (F-069). Callers test
1127
+ membership instead of scanning the text once per id: that per-(document,
1128
+ id) scan made the time cycle grow with superseded ids x living documents."""
1129
+ return frozenset(KB_HEX_TOKEN_RE.findall(text))
1094
1130
 
1095
1131
 
1096
1132
  def _kb_df_entries(value):
@@ -1172,19 +1208,23 @@ def kb_time_cycle(docs):
1172
1208
  if (meta.get("status") or "").strip() == "SUPERSEDED":
1173
1209
  continue
1174
1210
  living.append((rel, text))
1175
- for rel, text in living:
1176
- for old in sorted(superseded):
1177
- if _kb_hex_bounded(text, old):
1178
- succs = superseded[old]
1179
- missing = tuple(x for x in succs
1180
- if not _kb_hex_bounded(text, x))
1181
- if missing:
1182
- cited.append((rel, old, missing, succs))
1211
+ # One token pass per living document, and none at all on a KB with no
1212
+ # supersession and no validity window (the orient line runs this at every
1213
+ # session start).
1214
+ tokens = ({rel: _kb_hex_tokens(text) for rel, text in living}
1215
+ if superseded or windows else {})
1216
+ for rel, _text in living:
1217
+ cites = tokens[rel] if tokens else frozenset()
1218
+ for old in cites.intersection(superseded):
1219
+ succs = superseded[old]
1220
+ missing = tuple(x for x in succs if x not in cites)
1221
+ if missing:
1222
+ cited.append((rel, old, missing, succs))
1183
1223
  today = _dt.date.today().isoformat()
1184
1224
  for topic_rel, rid, until in sorted(windows):
1185
1225
  if until <= today:
1186
- by = tuple(rel for rel, text in living
1187
- if rel != topic_rel and _kb_hex_bounded(text, rid))
1226
+ by = tuple(rel for rel, _text in living
1227
+ if rel != topic_rel and rid in tokens[rel])
1188
1228
  expired.append((topic_rel, rid, until, by))
1189
1229
  rev = {}
1190
1230
  node_meta = {}
@@ -834,6 +834,10 @@ def build_registry(root):
834
834
  return ""
835
835
  stamp = max(str(m.get("updated") or m.get("since") or "").strip() or "0000-00-00"
836
836
  for _p, m in rows)
837
+ return _render_registry(root, rows, stamp)
838
+
839
+
840
+ def _render_registry(root, rows, stamp):
837
841
  lines = ["# Handoff — workstream registry",
838
842
  f"Date: {stamp} (UTC)",
839
843
  "",
@@ -867,6 +871,35 @@ def parse_registry_rows(text):
867
871
  return ids
868
872
 
869
873
 
874
+ def is_generated_registry(text):
875
+ # "Ours" = written by ANY family entry point: the header is WRITTEN with
876
+ # entry_script(), so recognition must not hard-code one distribution's
877
+ # script name (a registry generated by mkt_check.py is just as generated).
878
+ return bool(re.search(r"GENERATED by \S+ index - do not edit by hand", text))
879
+
880
+
881
+ def generated_registry_rows(text):
882
+ """Workstream rows of a generated registry, ignoring the project notes
883
+ (free text that may carry tables of its own)."""
884
+ return parse_registry_rows(text.split("\n## Project-wide notes", 1)[0])
885
+
886
+
887
+ def build_empty_registry(root, existing):
888
+ """The generated registry once the last source is gone (F1, found by F-068).
889
+
890
+ Only for a registry that is already ours: a hand-written handoff with no
891
+ sources is a project that never converted, and stays untouched. The Date is
892
+ kept while the view is already empty -- re-running index on a clean tree
893
+ must not change it -- and becomes today's when rows are being dropped:
894
+ there is no source left whose `updated:` could date the closure."""
895
+ m = re.search(r"Date:\s*(\d{4}-\d{2}-\d{2})", existing)
896
+ if m and not generated_registry_rows(existing):
897
+ stamp = m.group(1)
898
+ else:
899
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d")
900
+ return _render_registry(root, [], stamp)
901
+
902
+
870
903
  def registry_conversion_blockers(root):
871
904
  """What stops `index` from writing over a hand-written handoff.md.
872
905
 
@@ -879,10 +912,7 @@ def registry_conversion_blockers(root):
879
912
  if not hand.is_file():
880
913
  return []
881
914
  text = read_text(hand)
882
- # "Already ours" = written by ANY family entry point: the header is WRITTEN
883
- # with entry_script(), so recognition must not hard-code one distribution's
884
- # script name (a registry generated by mkt_check.py is just as generated).
885
- if re.search(r"GENERATED by \S+ index - do not edit by hand", text):
915
+ if is_generated_registry(text):
886
916
  return []
887
917
  blockers = []
888
918
  known = {str(m.get("workstream")).strip() for _p, m in list_workstreams(root)}
@@ -1015,10 +1045,21 @@ def cmd_index(root):
1015
1045
  def rc_registry(root):
1016
1046
  """Write the generated workstream registry, or refuse and say why (F-028)."""
1017
1047
  ws = list_workstreams(root)
1048
+ hand = ai_path(root, "audit", "handoff.md")
1018
1049
  if not ws:
1019
- return 0 # no sources: a hand-written handoff is untouched
1050
+ # No sources: a hand-written handoff is untouched; a generated one is
1051
+ # emptied, or the last closed workstream would stay listed forever.
1052
+ if not hand.is_file():
1053
+ return 0
1054
+ existing = read_text(hand)
1055
+ if not is_generated_registry(existing):
1056
+ return 0
1057
+ empty = build_empty_registry(root, existing)
1058
+ if norm_text(existing) != norm_text(empty):
1059
+ hand.write_text(empty, encoding="utf-8")
1060
+ print(f"[ok] workstream registry emptied (no open workstreams): {hand}")
1061
+ return 0
1020
1062
  blockers = registry_conversion_blockers(root)
1021
- hand = ai_path(root, "audit", "handoff.md")
1022
1063
  if blockers:
1023
1064
  print(f"[ERROR] {docs_dir()}/audit/handoff.md NOT regenerated -- it still holds "
1024
1065
  "state no source accounts for:")
@@ -1496,6 +1537,12 @@ def cmd_validate(root, strict=False, hybrid=False):
1496
1537
  "workstream — decide which file owns it")
1497
1538
  else:
1498
1539
  seen[wid] = p.name
1540
+ elif hand.is_file():
1541
+ text = read_text(hand)
1542
+ if is_generated_registry(text) and generated_registry_rows(text):
1543
+ errors.append(f"{docs_dir()}/audit/handoff.md lists workstreams no HANDOFF_*.md "
1544
+ "source exists for any more: run 'sdlc_check.py index' to drop "
1545
+ "the closed rows")
1499
1546
  if hand.is_file():
1500
1547
  m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
1501
1548
  if not m:
@@ -9,14 +9,14 @@
9
9
  "review.md": "0b94b48b0ef3b407c3fe8b2a503e89fb30af4dfcf37519b5541ff017cfa83f00",
10
10
  "routing.md": "2f90556cb8516599b9fd6ebf0b5f61dffc107ff186a4c14aed0e483b82cf91d2",
11
11
  "scripts/entry_point.py": "4a560d4d41db989d3426182f1b24270cbb934b3db0f3246c700578a4ed93089b",
12
- "scripts/knowledge.py": "95c23ddd245790c9cb20dbd3f44e15647354a2687260550c7cba92f6f7c44544",
13
- "scripts/sdlc_core.py": "4d2c7a8e1443ca6d8595623c38cd93612da66a1824237197f83cb3633c79bb84",
12
+ "scripts/knowledge.py": "15bb92f5f11baf82b1b19f97e52229617ac2c5b8d5d0fd910171de57aeae314f",
13
+ "scripts/sdlc_core.py": "2359bd070caf8674f8a009eaebf637339ddce2720560afdc48014d19cb910c72",
14
14
  "scripts/shared_files.py": "e0d48e3dd2fe4cb11c11c2e6f874908e6211451403e6d0cac7af583c4d4fd467",
15
15
  "scripts/test_audit_refs.py": "a6f06e73f4c9469dd64bed283024b6b37294e6c26f171d8cbdbdfd03771a6884",
16
16
  "scripts/test_docs_root.py": "19753e0316c0df63ed321d24b411a9ff36eea2058421fd69fc57e507d75566d0",
17
17
  "scripts/test_domain_rules.py": "33542b5dc2bb77c05ece55cf0446065cc4226c763ef5e040aa77364f226937bb",
18
18
  "scripts/test_drift.py": "c7b7121a8a308b0f3fe12c0c0c346444e632b7aaeff47dc8818b621e5c20cb38",
19
- "scripts/test_merge_safety.py": "ed52bed751386e47fd680942c2315f1a1fcc83206a9e2146acc40fff6967ccf4",
19
+ "scripts/test_merge_safety.py": "8b9241077bf7ecd6dd335907c640044ab38968ff4c9fdc464450e732cb77adf4",
20
20
  "scripts/test_migrate.py": "c129823bf4feb0614acd975351632a6ae62d7896a0ef32724419a8e9335e3110",
21
21
  "scripts/test_plan.py": "4c011a63aa834b55e9c5d13fb4ea76b131b742356001364b0a508435ba013e27",
22
22
  "scripts/test_project_memory.py": "2cb85cef01161d881a11d84cfe9ebb72915104e8d1822e4de739f7d00ae6cd59",