@antoneeo/course-creator 0.1.0-beta.1 → 0.1.0-beta.10
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 +65 -0
- package/README.md +4 -2
- package/gemini-extension.json +1 -1
- package/package.json +7 -2
- 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/ENFORCEMENT.md +2 -2
- package/skills/course-creator/SKILL.md +7 -4
- package/skills/course-creator/dispatch.md +5 -5
- package/skills/course-creator/docs_site/app.js +1133 -0
- package/skills/course-creator/docs_site/index.html +17 -0
- package/skills/course-creator/docs_site/strings.js +323 -0
- package/skills/course-creator/docs_site/style.css +288 -0
- package/skills/course-creator/elicitation.md +23 -8
- package/skills/course-creator/review.md +107 -13
- package/skills/course-creator/scripts/course_check.py +132 -132
- package/skills/course-creator/scripts/docs_site.py +1379 -0
- package/skills/course-creator/scripts/knowledge.py +102 -46
- package/skills/course-creator/scripts/sdlc_core.py +389 -15
- package/skills/course-creator/scripts/shared_files.py +8 -0
- package/skills/course-creator/scripts/shared_manifest.json +34 -27
- package/skills/course-creator/templates.md +51 -8
- package/skills/course-creator/vision.md +308 -245
|
@@ -125,6 +125,21 @@ def kb_unquote(value):
|
|
|
125
125
|
return v
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def frontmatter_date(meta):
|
|
129
|
+
"""A note's or a sidecar's `date:` as `YYYY-MM-DD`, or None (F-073).
|
|
130
|
+
Quotes stripped, a datetime suffix tolerated, an impossible date refused --
|
|
131
|
+
the one parser behind orient's recency probe and any chronology reader."""
|
|
132
|
+
m = re.match(r"(\d{4}-\d{2}-\d{2})", kb_unquote((meta or {}).get("date")))
|
|
133
|
+
if not m:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
import datetime as _dt
|
|
137
|
+
_dt.date.fromisoformat(m.group(1))
|
|
138
|
+
except ValueError:
|
|
139
|
+
return None
|
|
140
|
+
return m.group(1)
|
|
141
|
+
|
|
142
|
+
|
|
128
143
|
def kb_parse_scope(text):
|
|
129
144
|
"""'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
|
|
130
145
|
('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
|
|
@@ -336,6 +351,17 @@ def kb_check_claims(root):
|
|
|
336
351
|
if rel not in fm_cache:
|
|
337
352
|
fm_cache[rel] = _note_frontmatter(root, rel)
|
|
338
353
|
return fm_cache[rel]
|
|
354
|
+
# F-069: the same per-run memo for the source paths themselves -- each
|
|
355
|
+
# distinct path is resolved and stat'ed once, and each cited extraction
|
|
356
|
+
# read once (loc_cache, owned here, dropped when this call returns).
|
|
357
|
+
src_cache = {}
|
|
358
|
+
loc_cache = {}
|
|
359
|
+
|
|
360
|
+
def _resolved(path_s):
|
|
361
|
+
if path_s not in src_cache:
|
|
362
|
+
t = sdlc_core.confine_under(root, path_s)
|
|
363
|
+
src_cache[path_s] = (t, t is not None and t.is_file())
|
|
364
|
+
return src_cache[path_s]
|
|
339
365
|
per_file_rows = []
|
|
340
366
|
for p in sorted(topics.glob("*.md")):
|
|
341
367
|
rel = "topics/" + p.name
|
|
@@ -357,16 +383,16 @@ def kb_check_claims(root):
|
|
|
357
383
|
errors.append("%s: source %r has no locator" % (where, src))
|
|
358
384
|
continue
|
|
359
385
|
path_s, loc = src.rsplit("#", 1)
|
|
360
|
-
target =
|
|
386
|
+
target, is_file = _resolved(path_s)
|
|
361
387
|
if target is None:
|
|
362
388
|
errors.append("%s: source %r escapes the docs root" % (where, src))
|
|
363
389
|
continue
|
|
364
|
-
if not
|
|
390
|
+
if not is_file:
|
|
365
391
|
errors.append("%s: source %r does not resolve — a claim whose "
|
|
366
392
|
"origin cannot be reopened is model knowledge"
|
|
367
393
|
% (where, src))
|
|
368
394
|
continue
|
|
369
|
-
kb_check_locator(target, loc, where, errors)
|
|
395
|
+
kb_check_locator(target, loc, where, errors, loc_cache)
|
|
370
396
|
# --- provenance ---
|
|
371
397
|
prov = row["prov"]
|
|
372
398
|
# Resolved ONCE, for every provenance: the non-GIVEN classes read it
|
|
@@ -556,8 +582,14 @@ def kb_check_claims(root):
|
|
|
556
582
|
return errors, warnings, notes
|
|
557
583
|
|
|
558
584
|
|
|
559
|
-
def kb_check_locator(target, loc, where, errors):
|
|
560
|
-
"""A locator must address an existing span of the stored bytes.
|
|
585
|
+
def kb_check_locator(target, loc, where, errors, cache=None):
|
|
586
|
+
"""A locator must address an existing span of the stored bytes.
|
|
587
|
+
|
|
588
|
+
`cache` is an optional dict the CALLER owns for one run (F-069): keyed by
|
|
589
|
+
the path actually read, it holds only what the checks compare against --
|
|
590
|
+
page lengths, a line count, or False for a missing extraction -- never the
|
|
591
|
+
text. Findings are still appended per call, so per row. Without it, every
|
|
592
|
+
call reads the file, as before."""
|
|
561
593
|
m = LOC_PAGE_RE.match(loc)
|
|
562
594
|
if m:
|
|
563
595
|
page, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
@@ -566,17 +598,24 @@ def kb_check_locator(target, loc, where, errors):
|
|
|
566
598
|
return
|
|
567
599
|
# offsets address the stored extraction (<stem>.txt beside the original)
|
|
568
600
|
ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
|
|
569
|
-
|
|
601
|
+
key = ("p", ext)
|
|
602
|
+
if cache is None or key not in cache:
|
|
603
|
+
lengths = ([len(pg) for pg in sdlc_core.read_text(ext).split("\f")]
|
|
604
|
+
if ext.is_file() else False)
|
|
605
|
+
if cache is not None:
|
|
606
|
+
cache[key] = lengths
|
|
607
|
+
else:
|
|
608
|
+
lengths = cache[key]
|
|
609
|
+
if lengths is False:
|
|
570
610
|
errors.append("%s: no stored extraction %s for locator %r — offsets "
|
|
571
611
|
"must address kept bytes" % (where, ext.name, loc))
|
|
572
612
|
return
|
|
573
|
-
|
|
574
|
-
if page < 1 or page > len(pages):
|
|
613
|
+
if page < 1 or page > len(lengths):
|
|
575
614
|
errors.append("%s: locator %r addresses page %d of %d" %
|
|
576
|
-
(where, loc, page, len(
|
|
577
|
-
elif b >
|
|
615
|
+
(where, loc, page, len(lengths)))
|
|
616
|
+
elif b > lengths[page - 1]:
|
|
578
617
|
errors.append("%s: locator %r spans past the end of page %d "
|
|
579
|
-
"(%d chars)" % (where, loc, page,
|
|
618
|
+
"(%d chars)" % (where, loc, page, lengths[page - 1]))
|
|
580
619
|
return
|
|
581
620
|
m = LOC_LINE_RE.match(loc)
|
|
582
621
|
if m:
|
|
@@ -584,7 +623,13 @@ def kb_check_locator(target, loc, where, errors):
|
|
|
584
623
|
if a > b or a < 1:
|
|
585
624
|
errors.append("%s: locator %r has an invalid line range" % (where, loc))
|
|
586
625
|
return
|
|
587
|
-
|
|
626
|
+
key = ("L", target)
|
|
627
|
+
if cache is None or key not in cache:
|
|
628
|
+
n = sdlc_core.read_text(target).count("\n") + 1
|
|
629
|
+
if cache is not None:
|
|
630
|
+
cache[key] = n
|
|
631
|
+
else:
|
|
632
|
+
n = cache[key]
|
|
588
633
|
if b > n:
|
|
589
634
|
errors.append("%s: locator %r spans past line %d" % (where, loc, n))
|
|
590
635
|
return
|
|
@@ -1085,12 +1130,18 @@ KB_GENERATED_RE = re.compile(r"GENERATED by \S+ index - do not edit by hand")
|
|
|
1085
1130
|
KB_HEX12_RE = re.compile(r"^[0-9a-f]{12}$")
|
|
1086
1131
|
|
|
1087
1132
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1133
|
+
# A citation of a claim id is a run of exactly its 12 lowercase hex characters
|
|
1134
|
+
# NOT embedded in a longer hex run: a git hash sharing a substring must never
|
|
1135
|
+
# count as a citation (either side, either case). Two such bounded runs cannot
|
|
1136
|
+
# overlap, so findall returns every one of them.
|
|
1137
|
+
KB_HEX_TOKEN_RE = re.compile(r"(?<![0-9a-fA-F])[0-9a-f]{12}(?![0-9a-fA-F])")
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _kb_hex_tokens(text):
|
|
1141
|
+
"""The ids a text cites, extracted in one pass (F-069). Callers test
|
|
1142
|
+
membership instead of scanning the text once per id: that per-(document,
|
|
1143
|
+
id) scan made the time cycle grow with superseded ids x living documents."""
|
|
1144
|
+
return frozenset(KB_HEX_TOKEN_RE.findall(text))
|
|
1094
1145
|
|
|
1095
1146
|
|
|
1096
1147
|
def _kb_df_entries(value):
|
|
@@ -1172,19 +1223,23 @@ def kb_time_cycle(docs):
|
|
|
1172
1223
|
if (meta.get("status") or "").strip() == "SUPERSEDED":
|
|
1173
1224
|
continue
|
|
1174
1225
|
living.append((rel, text))
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1226
|
+
# One token pass per living document, and none at all on a KB with no
|
|
1227
|
+
# supersession and no validity window (the orient line runs this at every
|
|
1228
|
+
# session start).
|
|
1229
|
+
tokens = ({rel: _kb_hex_tokens(text) for rel, text in living}
|
|
1230
|
+
if superseded or windows else {})
|
|
1231
|
+
for rel, _text in living:
|
|
1232
|
+
cites = tokens[rel] if tokens else frozenset()
|
|
1233
|
+
for old in cites.intersection(superseded):
|
|
1234
|
+
succs = superseded[old]
|
|
1235
|
+
missing = tuple(x for x in succs if x not in cites)
|
|
1236
|
+
if missing:
|
|
1237
|
+
cited.append((rel, old, missing, succs))
|
|
1183
1238
|
today = _dt.date.today().isoformat()
|
|
1184
1239
|
for topic_rel, rid, until in sorted(windows):
|
|
1185
1240
|
if until <= today:
|
|
1186
|
-
by = tuple(rel for rel,
|
|
1187
|
-
if rel != topic_rel and
|
|
1241
|
+
by = tuple(rel for rel, _text in living
|
|
1242
|
+
if rel != topic_rel and rid in tokens[rel])
|
|
1188
1243
|
expired.append((topic_rel, rid, until, by))
|
|
1189
1244
|
rev = {}
|
|
1190
1245
|
node_meta = {}
|
|
@@ -1491,16 +1546,17 @@ def memory_records(docs):
|
|
|
1491
1546
|
paths.append(p)
|
|
1492
1547
|
records = []
|
|
1493
1548
|
for p in sorted(set(paths), key=lambda p: p.relative_to(docs).as_posix()):
|
|
1494
|
-
|
|
1495
|
-
text = raw.decode("utf-8", errors="replace")
|
|
1549
|
+
text = p.read_bytes().decode("utf-8", errors="replace")
|
|
1496
1550
|
meta = sdlc_core.load_frontmatter(text.splitlines())
|
|
1497
1551
|
title = next((line[2:].strip() for line in text.splitlines()
|
|
1498
1552
|
if line.startswith("# ")), p.stem)
|
|
1553
|
+
# LF-normalized like the guides' source_hash: an autocrlf checkout must
|
|
1554
|
+
# regenerate the same catalog as an LF one.
|
|
1499
1555
|
records.append((p.relative_to(docs).as_posix(),
|
|
1500
1556
|
kb_unquote(meta.get("domain")) or default,
|
|
1501
1557
|
", ".join(_as_list(meta.get("topics", ""))),
|
|
1502
1558
|
kb_unquote(meta.get("description")) or title,
|
|
1503
|
-
|
|
1559
|
+
sdlc_core.sha256_file(p)))
|
|
1504
1560
|
return records
|
|
1505
1561
|
|
|
1506
1562
|
|
|
@@ -1662,17 +1718,18 @@ def kb_cmd_validate(root, docs, strict=False, hybrid=False):
|
|
|
1662
1718
|
|
|
1663
1719
|
def kb_cmd_check(root, docs, strict=False, hybrid=False):
|
|
1664
1720
|
# The spine's check owns its banners and summary line: reuse it whole, so a
|
|
1665
|
-
# tree with no kb surface gets byte-identical output. The kb checks
|
|
1666
|
-
#
|
|
1667
|
-
|
|
1668
|
-
|
|
1721
|
+
# tree with no kb surface gets byte-identical output. The kb checks ride its
|
|
1722
|
+
# hooks -- index freshness in the validate stage, graph/corpus as sections
|
|
1723
|
+
# only when the surface exists -- so the summary is printed after all of them.
|
|
1724
|
+
sections = ()
|
|
1669
1725
|
if (docs / "topics").is_dir() or (docs / "corpus").is_dir():
|
|
1670
1726
|
cycle = kb_time_cycle(docs)
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1727
|
+
sections = (("graph", lambda: kb_cmd_graph(docs, cycle)),
|
|
1728
|
+
("corpus", lambda: kb_cmd_corpus(docs, cycle)))
|
|
1729
|
+
return sdlc_core.cmd_check(
|
|
1730
|
+
root, strict=strict, hybrid=hybrid,
|
|
1731
|
+
extra_validate=lambda: max(_kb_extra_validate(docs), memory_validate(docs)),
|
|
1732
|
+
extra_sections=sections)
|
|
1676
1733
|
|
|
1677
1734
|
|
|
1678
1735
|
def kb_cmd_stale(root, docs, hybrid=False):
|
|
@@ -1903,14 +1960,13 @@ def kb_cmd_orient(argv):
|
|
|
1903
1960
|
# it -- the file was already fully read, so the cap bought
|
|
1904
1961
|
# only failure. load_frontmatter is the authoritative
|
|
1905
1962
|
# reader (fence-bounded, so a column-0 date: in the body
|
|
1906
|
-
# still cannot misdate the note);
|
|
1907
|
-
# dates
|
|
1963
|
+
# still cannot misdate the note); frontmatter_date covers
|
|
1964
|
+
# quoted dates and tolerates a datetime suffix.
|
|
1908
1965
|
meta = sdlc_core.load_frontmatter(
|
|
1909
1966
|
sdlc_core.read_text(p).splitlines()) or {}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
stamp = _dt.date.fromisoformat(m.group(1))
|
|
1967
|
+
day = frontmatter_date(meta)
|
|
1968
|
+
if day:
|
|
1969
|
+
stamp = _dt.date.fromisoformat(day)
|
|
1914
1970
|
except Exception:
|
|
1915
1971
|
pass
|
|
1916
1972
|
if stamp is None:
|