@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
|
@@ -89,6 +89,14 @@ ARCHITECT_PASS_EPOCH = "2026-07-28"
|
|
|
89
89
|
# row. Same grandfathering discipline as the pass above -- never nag work that
|
|
90
90
|
# predates the rule.
|
|
91
91
|
DESIGN_REVIEW_EPOCH = "2026-07-28"
|
|
92
|
+
# Chronology (F-073): an L3 ANALYSIS started, or an L3 HANDOFF updated, on/after
|
|
93
|
+
# this date is read for undated Diary text and a multi-step `next:`. Advisories,
|
|
94
|
+
# like the two gates above: a fixed epoch cannot protect a late upgrader, so a
|
|
95
|
+
# warning here would redden --strict CI on work written before the upgrade.
|
|
96
|
+
CHRONOLOGY_EPOCH = "2026-10-18"
|
|
97
|
+
NEXT_MAX_CHARS = 240 # this repository's longest `next:` was 214 at the epoch
|
|
98
|
+
ID_SCAN_MAX_REFS = 300 # A3 is skipped above it (one advisory says so, T9)
|
|
99
|
+
ID_SCAN_REFS_PER_CALL = 50 # keeps one git command line far below 32k chars
|
|
92
100
|
def review_log_rel():
|
|
93
101
|
return f"{docs_dir()}/audit/reviews/REVIEW_LOG.md"
|
|
94
102
|
# Component Map 'Where' refs: a dotted token counts as a path only with one of
|
|
@@ -834,6 +842,10 @@ def build_registry(root):
|
|
|
834
842
|
return ""
|
|
835
843
|
stamp = max(str(m.get("updated") or m.get("since") or "").strip() or "0000-00-00"
|
|
836
844
|
for _p, m in rows)
|
|
845
|
+
return _render_registry(root, rows, stamp)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _render_registry(root, rows, stamp):
|
|
837
849
|
lines = ["# Handoff — workstream registry",
|
|
838
850
|
f"Date: {stamp} (UTC)",
|
|
839
851
|
"",
|
|
@@ -867,6 +879,35 @@ def parse_registry_rows(text):
|
|
|
867
879
|
return ids
|
|
868
880
|
|
|
869
881
|
|
|
882
|
+
def is_generated_registry(text):
|
|
883
|
+
# "Ours" = written by ANY family entry point: the header is WRITTEN with
|
|
884
|
+
# entry_script(), so recognition must not hard-code one distribution's
|
|
885
|
+
# script name (a registry generated by mkt_check.py is just as generated).
|
|
886
|
+
return bool(re.search(r"GENERATED by \S+ index - do not edit by hand", text))
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def generated_registry_rows(text):
|
|
890
|
+
"""Workstream rows of a generated registry, ignoring the project notes
|
|
891
|
+
(free text that may carry tables of its own)."""
|
|
892
|
+
return parse_registry_rows(text.split("\n## Project-wide notes", 1)[0])
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def build_empty_registry(root, existing):
|
|
896
|
+
"""The generated registry once the last source is gone (F1, found by F-068).
|
|
897
|
+
|
|
898
|
+
Only for a registry that is already ours: a hand-written handoff with no
|
|
899
|
+
sources is a project that never converted, and stays untouched. The Date is
|
|
900
|
+
kept while the view is already empty -- re-running index on a clean tree
|
|
901
|
+
must not change it -- and becomes today's when rows are being dropped:
|
|
902
|
+
there is no source left whose `updated:` could date the closure."""
|
|
903
|
+
m = re.search(r"Date:\s*(\d{4}-\d{2}-\d{2})", existing)
|
|
904
|
+
if m and not generated_registry_rows(existing):
|
|
905
|
+
stamp = m.group(1)
|
|
906
|
+
else:
|
|
907
|
+
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
908
|
+
return _render_registry(root, [], stamp)
|
|
909
|
+
|
|
910
|
+
|
|
870
911
|
def registry_conversion_blockers(root):
|
|
871
912
|
"""What stops `index` from writing over a hand-written handoff.md.
|
|
872
913
|
|
|
@@ -879,10 +920,7 @@ def registry_conversion_blockers(root):
|
|
|
879
920
|
if not hand.is_file():
|
|
880
921
|
return []
|
|
881
922
|
text = read_text(hand)
|
|
882
|
-
|
|
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):
|
|
923
|
+
if is_generated_registry(text):
|
|
886
924
|
return []
|
|
887
925
|
blockers = []
|
|
888
926
|
known = {str(m.get("workstream")).strip() for _p, m in list_workstreams(root)}
|
|
@@ -1015,10 +1053,21 @@ def cmd_index(root):
|
|
|
1015
1053
|
def rc_registry(root):
|
|
1016
1054
|
"""Write the generated workstream registry, or refuse and say why (F-028)."""
|
|
1017
1055
|
ws = list_workstreams(root)
|
|
1056
|
+
hand = ai_path(root, "audit", "handoff.md")
|
|
1018
1057
|
if not ws:
|
|
1019
|
-
|
|
1058
|
+
# No sources: a hand-written handoff is untouched; a generated one is
|
|
1059
|
+
# emptied, or the last closed workstream would stay listed forever.
|
|
1060
|
+
if not hand.is_file():
|
|
1061
|
+
return 0
|
|
1062
|
+
existing = read_text(hand)
|
|
1063
|
+
if not is_generated_registry(existing):
|
|
1064
|
+
return 0
|
|
1065
|
+
empty = build_empty_registry(root, existing)
|
|
1066
|
+
if norm_text(existing) != norm_text(empty):
|
|
1067
|
+
hand.write_text(empty, encoding="utf-8")
|
|
1068
|
+
print(f"[ok] workstream registry emptied (no open workstreams): {hand}")
|
|
1069
|
+
return 0
|
|
1020
1070
|
blockers = registry_conversion_blockers(root)
|
|
1021
|
-
hand = ai_path(root, "audit", "handoff.md")
|
|
1022
1071
|
if blockers:
|
|
1023
1072
|
print(f"[ERROR] {docs_dir()}/audit/handoff.md NOT regenerated -- it still holds "
|
|
1024
1073
|
"state no source accounts for:")
|
|
@@ -1060,6 +1109,298 @@ def section_body(text, aliases):
|
|
|
1060
1109
|
return None
|
|
1061
1110
|
|
|
1062
1111
|
|
|
1112
|
+
def section_lines(text, aliases):
|
|
1113
|
+
"""(heading_line, body) for the first matching `## ` section, or None: the
|
|
1114
|
+
body NOT stripped, so body line n is file line heading_line + n, and an
|
|
1115
|
+
indented first line keeps its indentation. The readers below use it; a
|
|
1116
|
+
stripped body would shift every line number and move ` - DATE` to column 0."""
|
|
1117
|
+
lines = (text or "").splitlines()
|
|
1118
|
+
for i, line in enumerate(lines):
|
|
1119
|
+
if any(line.startswith(a) for a in aliases):
|
|
1120
|
+
body = []
|
|
1121
|
+
for nxt in lines[i + 1:]:
|
|
1122
|
+
if nxt.startswith("## "):
|
|
1123
|
+
break
|
|
1124
|
+
body.append(nxt)
|
|
1125
|
+
return i + 1, "\n".join(body)
|
|
1126
|
+
return None
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
# ---------------------------------------------------------------- chronology
|
|
1130
|
+
# One grammar for a dated entry (F-073), read by the validator and by any
|
|
1131
|
+
# consumer that lists events (F-072's chronology): both call these readers, so
|
|
1132
|
+
# they cannot disagree on what an event is. The readers return openers only --
|
|
1133
|
+
# date, label, line -- and format nothing for display.
|
|
1134
|
+
|
|
1135
|
+
DIARY_SECTION = next(s for s in ANALYSIS_SECTIONS if s[0] == "## Diary")
|
|
1136
|
+
_ISO = r"\d{4}-\d{2}-\d{2}"
|
|
1137
|
+
# At column 0: optional list marker, optional `###`/`####`, optional bold, then
|
|
1138
|
+
# the ISO date and a word boundary. The date FIRST is the rule; markup is
|
|
1139
|
+
# tolerated so the dated forms already written stay readable with no migration.
|
|
1140
|
+
ENTRY_OPENER = re.compile(r"^(?:([-*]) |(#{3,4}) )?(\*\*)?(" + _ISO + r")\b(.*)$")
|
|
1141
|
+
_LABEL_SEP = re.compile(r"^(?:\*\*)?\s*(?:[—–:-])?\s*")
|
|
1142
|
+
_REQUEST_HEADING = re.compile(r"^### (R\d+) [—–-] (" + _ISO + r") [—–-] (.+)$")
|
|
1143
|
+
_ADR_BODY_DATE = re.compile(r"^\**Date:\**\s*(" + _ISO + r")\b", re.M)
|
|
1144
|
+
_ADR_NAME_DATE = re.compile(r"^ADR_(" + _ISO + r")_", re.I)
|
|
1145
|
+
UNIT_ID_RE = re.compile(r"\b([A-Z]-\d{3,})\b")
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _entry_label(bold, rest):
|
|
1149
|
+
rest = _LABEL_SEP.sub("", rest, count=1)
|
|
1150
|
+
if bold and "**" in rest:
|
|
1151
|
+
rest = rest.split("**", 1)[0]
|
|
1152
|
+
else:
|
|
1153
|
+
rest = re.split(r"(?<=[.!?])\s", rest, maxsplit=1)[0]
|
|
1154
|
+
return rest.strip(" .:—–-*")[:120]
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _opener_at(lines, i):
|
|
1158
|
+
"""The match when lines[i] opens an entry. It must also START A BLOCK -- the
|
|
1159
|
+
line above is blank, absent or a heading, or the line is itself a list item
|
|
1160
|
+
or a heading -- so a hard-wrapped prose line that happens to begin with a
|
|
1161
|
+
date is never an event. (A separator after the date is NOT required: real
|
|
1162
|
+
openers read `DATE (qualifier)` and `DATE word`.)"""
|
|
1163
|
+
m = ENTRY_OPENER.match(lines[i])
|
|
1164
|
+
if not m:
|
|
1165
|
+
return None
|
|
1166
|
+
prev = lines[i - 1] if i else ""
|
|
1167
|
+
if not prev.strip() or prev.startswith("#") or m.group(1) or m.group(2):
|
|
1168
|
+
return m
|
|
1169
|
+
return None
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def entry_openers(body):
|
|
1173
|
+
"""[(date, label, line)] -- one per dated entry opener; line is 1-based
|
|
1174
|
+
within `body`. An undated paragraph belongs to the entry above it. Comments
|
|
1175
|
+
are blanked first, exactly as `undated_diary_lines` does, so an entry
|
|
1176
|
+
written under the template's comment is an event to both."""
|
|
1177
|
+
lines = _blank_comments(body).splitlines()
|
|
1178
|
+
out = []
|
|
1179
|
+
for i in range(len(lines)):
|
|
1180
|
+
m = _opener_at(lines, i)
|
|
1181
|
+
if m:
|
|
1182
|
+
out.append((m.group(4), _entry_label(m.group(3), m.group(5)), i + 1))
|
|
1183
|
+
return out
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _blank_comments(text):
|
|
1187
|
+
# comments become blank lines, so line numbers still point at the file's lines
|
|
1188
|
+
return re.sub(r"<!--.*?-->", lambda m: "\n" * m.group(0).count("\n"), text or "",
|
|
1189
|
+
flags=re.S)
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def undated_diary_lines(body):
|
|
1193
|
+
"""[(line, text)] a Diary body cannot date: text before the first opener,
|
|
1194
|
+
and any `### ` heading that is not an opener. `####` and deeper headings are
|
|
1195
|
+
sub-structure of an entry and stay legal. Comments are ignored."""
|
|
1196
|
+
lines = _blank_comments(body).splitlines()
|
|
1197
|
+
out, seen_opener = [], False
|
|
1198
|
+
for i, line in enumerate(lines):
|
|
1199
|
+
if _opener_at(lines, i):
|
|
1200
|
+
seen_opener = True
|
|
1201
|
+
elif line.startswith("### "):
|
|
1202
|
+
out.append((i + 1, line.strip()))
|
|
1203
|
+
elif not seen_opener and line.strip():
|
|
1204
|
+
out.append((i + 1, line.strip()))
|
|
1205
|
+
return out
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def request_entries(text):
|
|
1209
|
+
"""(entries, unparsed) for a request record: entries are
|
|
1210
|
+
[(rid, date, label, line)]; a `### ` heading that does not match
|
|
1211
|
+
`### R<n> — YYYY-MM-DD — <label>` is returned in unparsed as (line, text),
|
|
1212
|
+
never dropped."""
|
|
1213
|
+
entries, unparsed = [], []
|
|
1214
|
+
for i, line in enumerate((text or "").splitlines()):
|
|
1215
|
+
if not line.startswith("### "):
|
|
1216
|
+
continue
|
|
1217
|
+
m = _REQUEST_HEADING.match(line.rstrip())
|
|
1218
|
+
if m:
|
|
1219
|
+
entries.append((m.group(1), m.group(2), m.group(3).strip(), i + 1))
|
|
1220
|
+
else:
|
|
1221
|
+
unparsed.append((i + 1, line.rstrip()))
|
|
1222
|
+
return entries, unparsed
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def adr_date(name, text):
|
|
1226
|
+
"""An ADR's date: `**Date:** YYYY-MM-DD` in the body, else the
|
|
1227
|
+
`ADR_YYYY-MM-DD_*` file name, else None."""
|
|
1228
|
+
m = _ADR_BODY_DATE.search(text or "") or _ADR_NAME_DATE.match(name or "")
|
|
1229
|
+
return m.group(1) if m else None
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def _meta_date(meta, key):
|
|
1233
|
+
return parse_iso(str(meta.get(key) or "").strip().strip("'\"")[:10])
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def chronology_advisories(root):
|
|
1237
|
+
"""A1 (undated Diary text) and A2 (`next:` is not one step), as advisory
|
|
1238
|
+
strings. Gated on `level: L3` and on a date the file carries -- never an
|
|
1239
|
+
mtime -- so L1/L2 work and anything written before the epoch see nothing."""
|
|
1240
|
+
epoch = parse_iso(CHRONOLOGY_EPOCH)
|
|
1241
|
+
out = []
|
|
1242
|
+
for p, meta, text in list_analyses(root):
|
|
1243
|
+
started = _meta_date(meta, "start_date")
|
|
1244
|
+
if str(meta.get("level", "")).upper() != "L3" or not started or started < epoch:
|
|
1245
|
+
continue
|
|
1246
|
+
section = section_lines(text, DIARY_SECTION)
|
|
1247
|
+
if section is None:
|
|
1248
|
+
continue
|
|
1249
|
+
head, body = section
|
|
1250
|
+
rel = "solutions/" + p.name
|
|
1251
|
+
lines = undated_diary_lines(body)
|
|
1252
|
+
preface = [(n, t) for n, t in lines if not t.startswith("### ")]
|
|
1253
|
+
if preface:
|
|
1254
|
+
out.append(f"{rel}: line {head + preface[0][0]}: Diary text '{preface[0][1][:60]}' "
|
|
1255
|
+
"comes before the first dated entry -- open entries with "
|
|
1256
|
+
"**YYYY-MM-DD — label.**, and write the current state as the latest "
|
|
1257
|
+
"dated entry")
|
|
1258
|
+
for n, t in lines:
|
|
1259
|
+
if t.startswith("### "):
|
|
1260
|
+
out.append(f"{rel}: line {head + n}: Diary heading '{t[:60]}' is not a dated "
|
|
1261
|
+
"entry -- open entries with **YYYY-MM-DD — label.**")
|
|
1262
|
+
for p, meta in list_workstreams(root):
|
|
1263
|
+
updated = _meta_date(meta, "updated")
|
|
1264
|
+
if str(meta.get("level", "")).upper() != "L3" or not updated or updated < epoch:
|
|
1265
|
+
continue
|
|
1266
|
+
nxt = str(meta.get("next") or "").strip()
|
|
1267
|
+
if len(nxt) > NEXT_MAX_CHARS:
|
|
1268
|
+
out.append(f"audit/{p.name}: next: is {len(nxt)} chars (> {NEXT_MAX_CHARS}): one "
|
|
1269
|
+
"next step here, the state goes to the Diary as a dated entry")
|
|
1270
|
+
return out
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def _open_units(root):
|
|
1274
|
+
"""{id: {analysis name}} for the tree's open L3 units: an L3 ANALYSIS that is
|
|
1275
|
+
PLANNED or IN_PROGRESS, or a request record (L3 only, r26) whose `analysis:`
|
|
1276
|
+
is absent from the tree or names such an open ANALYSIS. A closed unit's
|
|
1277
|
+
record, kept forever, does not count."""
|
|
1278
|
+
units, present, open_names = {}, set(), set()
|
|
1279
|
+
for p, meta, _ in list_analyses(root):
|
|
1280
|
+
present.add(p.name)
|
|
1281
|
+
if (str(meta.get("level", "")).upper() == "L3"
|
|
1282
|
+
and meta.get("status") in ("PLANNED", "IN_PROGRESS")):
|
|
1283
|
+
m = UNIT_ID_RE.search(str(meta.get("id") or ""))
|
|
1284
|
+
if m:
|
|
1285
|
+
open_names.add(p.name)
|
|
1286
|
+
units.setdefault(m.group(1), set()).add(p.name)
|
|
1287
|
+
req = ai_path(root, "audit", "requests")
|
|
1288
|
+
if req.is_dir():
|
|
1289
|
+
for p in sorted(req.glob("REQUEST_*.md")):
|
|
1290
|
+
meta = load_frontmatter(read_text(p).splitlines())
|
|
1291
|
+
name = str(meta.get("analysis") or "").strip().strip("'\"`")
|
|
1292
|
+
m = UNIT_ID_RE.search(str(meta.get("unit") or meta.get("id") or ""))
|
|
1293
|
+
if m and name and (name not in present or name in open_names):
|
|
1294
|
+
units.setdefault(m.group(1), set()).add(name)
|
|
1295
|
+
return units
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _git_out(root, *args, timeout=30):
|
|
1299
|
+
r = subprocess.run(["git", *args], cwd=str(root), capture_output=True, text=True,
|
|
1300
|
+
encoding="utf-8", errors="replace", timeout=timeout)
|
|
1301
|
+
return r.stdout if r.returncode == 0 else None
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def ids_on_other_refs(root, max_refs=ID_SCAN_MAX_REFS):
|
|
1305
|
+
"""[(id, mine, theirs, [refs])] -- an open unit's id that another ref (heads,
|
|
1306
|
+
remotes, tags) uses for a DIFFERENT analysis. Identity is the ANALYSIS file
|
|
1307
|
+
name, declared by the ANALYSIS or announced by a request record's
|
|
1308
|
+
`analysis:`. One `git grep` over local refs, fixed arguments only (T8);
|
|
1309
|
+
as fresh as the last fetch. [] with no open L3 unit or no git; None when
|
|
1310
|
+
skipped -- above `max_refs` refs, or git failed or timed out -- so the
|
|
1311
|
+
caller can say the check did not run instead of reporting it clean."""
|
|
1312
|
+
mine = _open_units(root)
|
|
1313
|
+
if not mine or not git_available(root):
|
|
1314
|
+
return []
|
|
1315
|
+
try:
|
|
1316
|
+
head = (_git_out(root, "rev-parse", "HEAD") or "").strip()
|
|
1317
|
+
listing = _git_out(root, "for-each-ref",
|
|
1318
|
+
"--format=%(objectname) %(objecttype) %(*objecttype) %(refname)",
|
|
1319
|
+
"refs/heads", "refs/remotes", "refs/tags")
|
|
1320
|
+
if listing is None:
|
|
1321
|
+
return None
|
|
1322
|
+
refs = []
|
|
1323
|
+
for line in listing.split("\n"):
|
|
1324
|
+
parts = line.split(" ")
|
|
1325
|
+
if len(parts) == 3: # %(*objecttype) empty: not a tag
|
|
1326
|
+
parts.insert(2, "")
|
|
1327
|
+
if len(parts) != 4:
|
|
1328
|
+
continue
|
|
1329
|
+
sha, kind, peeled, ref = parts
|
|
1330
|
+
# only refs that resolve to a commit can be grepped (a tag on a blob
|
|
1331
|
+
# would fail the whole batch); the tree's own commit is not "another
|
|
1332
|
+
# ref"; symbolic HEADs repeat a branch
|
|
1333
|
+
if "commit" not in (kind, peeled) or ref.endswith("/HEAD") or sha == head:
|
|
1334
|
+
continue
|
|
1335
|
+
refs.append(ref)
|
|
1336
|
+
if not refs:
|
|
1337
|
+
return []
|
|
1338
|
+
if len(refs) > max_refs:
|
|
1339
|
+
return None
|
|
1340
|
+
# Pathspecs are relative to cwd=root, so a docs root inside a monorepo
|
|
1341
|
+
# subdirectory is found. -z keeps a path with ':' or non-ASCII intact
|
|
1342
|
+
# (quotePath off); -n plus the fence lines confine hits to frontmatter.
|
|
1343
|
+
docs = docs_dir()
|
|
1344
|
+
chunks = []
|
|
1345
|
+
for i in range(0, len(refs), ID_SCAN_REFS_PER_CALL): # command-line length
|
|
1346
|
+
r = subprocess.run(
|
|
1347
|
+
["git", "-c", "core.quotePath=false", "grep", "-z", "-n", "-I", "-E",
|
|
1348
|
+
r"^(id|unit|analysis):|^---\s*$", *refs[i:i + ID_SCAN_REFS_PER_CALL],
|
|
1349
|
+
"--", f"{docs}/solutions/ANALYSIS_*.md", f"{docs}/audit/requests/REQUEST_*.md"],
|
|
1350
|
+
cwd=str(root), capture_output=True, text=True, encoding="utf-8",
|
|
1351
|
+
errors="replace", timeout=60)
|
|
1352
|
+
if r.returncode not in (0, 1): # 1 = no match
|
|
1353
|
+
return None
|
|
1354
|
+
chunks.append(r.stdout)
|
|
1355
|
+
except Exception:
|
|
1356
|
+
return None
|
|
1357
|
+
per = {}
|
|
1358
|
+
for line in "".join(chunks).split("\n"): # not splitlines: \x1c.. are content
|
|
1359
|
+
parts = line.split("\0")
|
|
1360
|
+
if len(parts) != 3 or ":" not in parts[0] or not parts[1].isdigit():
|
|
1361
|
+
continue
|
|
1362
|
+
ref, path = parts[0].split(":", 1) # a ref name cannot hold ':'
|
|
1363
|
+
content = parts[2].rstrip("\r")
|
|
1364
|
+
st = per.setdefault((ref, path), {"fences": 0, "kv": {}})
|
|
1365
|
+
if int(parts[1]) == 1 and content.strip() == "---":
|
|
1366
|
+
st["fences"] = 1 # frontmatter opens on line 1
|
|
1367
|
+
elif st["fences"] == 1 and content.strip() == "---":
|
|
1368
|
+
st["fences"] = 2 # ...and closes here
|
|
1369
|
+
elif st["fences"] == 1:
|
|
1370
|
+
key, _, val = content.partition(":")
|
|
1371
|
+
st["kv"].setdefault(key, val.strip())
|
|
1372
|
+
found = {}
|
|
1373
|
+
for (ref, path), st in per.items():
|
|
1374
|
+
kv = st["kv"]
|
|
1375
|
+
fname = path.replace("\\", "/").rsplit("/", 1)[-1]
|
|
1376
|
+
name = fname if fname.startswith("ANALYSIS_") else kv.get("analysis", "").strip("'\"`")
|
|
1377
|
+
m = UNIT_ID_RE.search(kv.get("id") or kv.get("unit") or "")
|
|
1378
|
+
if not (m and name) or m.group(1) not in mine or name in mine[m.group(1)]:
|
|
1379
|
+
continue
|
|
1380
|
+
short = re.sub(r"^refs/(heads|remotes|tags)/", "", ref)
|
|
1381
|
+
found.setdefault((m.group(1), name), [])
|
|
1382
|
+
if short not in found[(m.group(1), name)]:
|
|
1383
|
+
found[(m.group(1), name)].append(short)
|
|
1384
|
+
return sorted((fid, ", ".join(sorted(mine[fid])), theirs, sorted(refs_))
|
|
1385
|
+
for (fid, theirs), refs_ in found.items())
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def id_advisories(root):
|
|
1389
|
+
"""A3 as advisory strings, for every entry point's `check`."""
|
|
1390
|
+
hits = ids_on_other_refs(root)
|
|
1391
|
+
if hits is None:
|
|
1392
|
+
return [f"id check skipped (more than {ID_SCAN_MAX_REFS} refs, or git grep failed "
|
|
1393
|
+
"or timed out) -- choose a new unit's id after fetching, as the first id "
|
|
1394
|
+
"free on every ref"]
|
|
1395
|
+
out = []
|
|
1396
|
+
for fid, mine, theirs, refs in hits:
|
|
1397
|
+
more = f" (+{len(refs) - 1} more)" if len(refs) > 1 else ""
|
|
1398
|
+
out.append(f"id {fid} ({mine}) is also used by {theirs} on {refs[0]}{more}: choose "
|
|
1399
|
+
"the first id free on every ref -- if this is the same unit under its "
|
|
1400
|
+
"old name, ignore it")
|
|
1401
|
+
return out
|
|
1402
|
+
|
|
1403
|
+
|
|
1063
1404
|
def design_review_due(meta):
|
|
1064
1405
|
"""True when an L3 ANALYSIS owes a design-review row (review.md moment 1):
|
|
1065
1406
|
implementation has started or finished, and it began on/after the gate
|
|
@@ -1354,10 +1695,10 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1354
1695
|
"doing the work it triggers")
|
|
1355
1696
|
# comment-stripped, anchored: a '<!-- TODO: the ## Capability Ledger -->'
|
|
1356
1697
|
# must not read as the section being present
|
|
1357
|
-
# Hybrid: the design lives in devPNT and
|
|
1358
|
-
#
|
|
1359
|
-
#
|
|
1360
|
-
#
|
|
1698
|
+
# Hybrid: the design lives in devPNT, and the agent review of it (the
|
|
1699
|
+
# same review.md review, run before the proposal) logs a row keyed on
|
|
1700
|
+
# its e_isp_/e_tdd_ doc_key, not on this filename -- so firing here
|
|
1701
|
+
# would be a permanent, unfixable false positive.
|
|
1361
1702
|
if not hybrid and design_review_due(meta) and not review_logged(root, p.name):
|
|
1362
1703
|
advisories.append(f"{rel}: L3 in implementation with no design-review row in "
|
|
1363
1704
|
f"{review_log_rel()} -- the design was reviewed by nobody but its "
|
|
@@ -1496,6 +1837,12 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1496
1837
|
"workstream — decide which file owns it")
|
|
1497
1838
|
else:
|
|
1498
1839
|
seen[wid] = p.name
|
|
1840
|
+
elif hand.is_file():
|
|
1841
|
+
text = read_text(hand)
|
|
1842
|
+
if is_generated_registry(text) and generated_registry_rows(text):
|
|
1843
|
+
errors.append(f"{docs_dir()}/audit/handoff.md lists workstreams no HANDOFF_*.md "
|
|
1844
|
+
"source exists for any more: run 'sdlc_check.py index' to drop "
|
|
1845
|
+
"the closed rows")
|
|
1499
1846
|
if hand.is_file():
|
|
1500
1847
|
m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
|
|
1501
1848
|
if not m:
|
|
@@ -1509,6 +1856,7 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1509
1856
|
except ValueError:
|
|
1510
1857
|
warnings.append("audit/handoff.md: date not parseable")
|
|
1511
1858
|
|
|
1859
|
+
advisories.extend(chronology_advisories(root)) # A1, A2 (F-073)
|
|
1512
1860
|
for a in advisories:
|
|
1513
1861
|
print(f"[note] {a}")
|
|
1514
1862
|
for w in warnings:
|
|
@@ -1869,15 +2217,30 @@ def print_orient_hook_note(root):
|
|
|
1869
2217
|
pass # detection must never break check
|
|
1870
2218
|
|
|
1871
2219
|
|
|
1872
|
-
def cmd_check(root, strict=False, hybrid=False):
|
|
2220
|
+
def cmd_check(root, strict=False, hybrid=False, extra_validate=None, extra_sections=()):
|
|
2221
|
+
"""An overlay adds its checks through the two hooks, never after the call:
|
|
2222
|
+
the summary line is the verdict, so every check that can fail runs before
|
|
2223
|
+
it. `extra_validate()` folds into the validate stage; each
|
|
2224
|
+
(title, fn) in `extra_sections` gets its own banner and summary rc."""
|
|
1873
2225
|
print("===== validate =====")
|
|
1874
2226
|
rc_v = cmd_validate(root, strict=strict, hybrid=hybrid)
|
|
2227
|
+
if extra_validate is not None:
|
|
2228
|
+
rc_v = max(rc_v, extra_validate())
|
|
2229
|
+
# A3 (F-073): check only -- it reads git refs, and validate stays file-local.
|
|
2230
|
+
# Advisory: printed, never part of any rc.
|
|
2231
|
+
for a in id_advisories(root):
|
|
2232
|
+
print(f"[note] {a}")
|
|
1875
2233
|
print("\n===== stale =====")
|
|
1876
2234
|
rc_s = cmd_stale(root, hybrid=hybrid)
|
|
1877
|
-
|
|
1878
|
-
|
|
2235
|
+
rcs = [("validate", rc_v), ("stale", rc_s)]
|
|
2236
|
+
for title, fn in extra_sections:
|
|
2237
|
+
print(f"\n===== {title} =====")
|
|
2238
|
+
rcs.append((title, fn()))
|
|
2239
|
+
failed = any(rc for _, rc in rcs)
|
|
2240
|
+
print(f"\ncheck: {'NOT CLEAN' if failed else 'CLEAN'} "
|
|
2241
|
+
f"({', '.join(f'{title} rc={rc}' for title, rc in rcs)})")
|
|
1879
2242
|
print_orient_hook_note(root)
|
|
1880
|
-
return 1 if
|
|
2243
|
+
return 1 if failed else 0
|
|
1881
2244
|
|
|
1882
2245
|
|
|
1883
2246
|
# --------------------------------------------------------------------- gate
|
|
@@ -2235,6 +2598,13 @@ def _is_pass(verdict):
|
|
|
2235
2598
|
return re.search(r"\bPASS\b", (verdict or "").upper()) is not None
|
|
2236
2599
|
|
|
2237
2600
|
|
|
2601
|
+
def _is_not_due(verdict):
|
|
2602
|
+
"""A review ruled not due (review.md, a delta that only restates judged
|
|
2603
|
+
work): logged so a skip stays visible, but it is neither a review nor a
|
|
2604
|
+
finding, so it stays out of every count below (F-071)."""
|
|
2605
|
+
return (verdict or "").strip().upper().startswith("NOT DUE")
|
|
2606
|
+
|
|
2607
|
+
|
|
2238
2608
|
def _print_read_cost():
|
|
2239
2609
|
"""The cost side, printed with the benefit and NEVER without it: a catch
|
|
2240
2610
|
rate with no cost term justifies unbounded doctrine, and a cost term with
|
|
@@ -2275,6 +2645,8 @@ def cmd_benefit(root):
|
|
|
2275
2645
|
_print_read_cost()
|
|
2276
2646
|
return 0
|
|
2277
2647
|
rows, unparsed = parse_review_log(read_text(log))
|
|
2648
|
+
not_due = [r for r in rows if _is_not_due(r["verdict"])]
|
|
2649
|
+
rows = [r for r in rows if not _is_not_due(r["verdict"])]
|
|
2278
2650
|
# `tier` carries the MOMENT in Standalone (design / closure) and the
|
|
2279
2651
|
# reviewer WEIGHT in Hybrid (deep / light / code / guide / vision). A
|
|
2280
2652
|
# Hybrid row is a real review whose moment this column does not state, so
|
|
@@ -2299,9 +2671,11 @@ def cmd_benefit(root):
|
|
|
2299
2671
|
unstated_code += n
|
|
2300
2672
|
classified = moments["design"] + moments["closure"]
|
|
2301
2673
|
print("rows parsed: %d rows unparsed: %d%s"
|
|
2302
|
-
% (len(rows), len(unparsed),
|
|
2674
|
+
% (len(rows) + len(not_due), len(unparsed),
|
|
2303
2675
|
(" -> " + "; ".join("line %d: %s" % u for u in unparsed[:5]))
|
|
2304
2676
|
if unparsed else ""))
|
|
2677
|
+
print("reviews ruled not due (logged, counted in no figure below): %d"
|
|
2678
|
+
% len(not_due))
|
|
2305
2679
|
print("reviews with a stated moment: %d moment not stated by `tier`: %d%s"
|
|
2306
2680
|
% (len(classified), sum(unstated.values()),
|
|
2307
2681
|
(" (" + ", ".join("%s x%d" % (t, n) for t, n
|
|
@@ -51,8 +51,16 @@ SHARED_FILES = (
|
|
|
51
51
|
"scripts/test_plan.py",
|
|
52
52
|
"scripts/test_drift.py",
|
|
53
53
|
"scripts/test_migrate.py",
|
|
54
|
+
"scripts/test_chronology.py",
|
|
54
55
|
"scripts/test_merge_safety.py",
|
|
55
56
|
"scripts/test_audit_refs.py",
|
|
57
|
+
# the docs site (F-072): generator, its battery and its page
|
|
58
|
+
"scripts/docs_site.py",
|
|
59
|
+
"scripts/test_docs_site.py",
|
|
60
|
+
"docs_site/index.html",
|
|
61
|
+
"docs_site/app.js",
|
|
62
|
+
"docs_site/style.css",
|
|
63
|
+
"docs_site/strings.js",
|
|
56
64
|
# domain-neutral doctrine
|
|
57
65
|
"review.md",
|
|
58
66
|
"routing.md",
|
|
@@ -1,27 +1,34 @@
|
|
|
1
|
-
{
|
|
2
|
-
"_comment": "Generated by shared_files.py --update. IDENTICAL in every distribution of the family: if two copies of this file differ, the spine has diverged. Never edit by hand.",
|
|
3
|
-
"files": {
|
|
4
|
-
"LICENSE": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
|
|
5
|
-
"NOTICE": "8414f424f7c1db19d89d74286fd2d6b1acdd9eedde910fa57a16d6835602e4bd",
|
|
6
|
-
"dispatch.md": "
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"scripts/
|
|
16
|
-
"scripts/
|
|
17
|
-
"scripts/
|
|
18
|
-
"scripts/
|
|
19
|
-
"scripts/
|
|
20
|
-
"scripts/
|
|
21
|
-
"scripts/
|
|
22
|
-
"scripts/
|
|
23
|
-
"scripts/
|
|
24
|
-
"scripts/
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Generated by shared_files.py --update. IDENTICAL in every distribution of the family: if two copies of this file differ, the spine has diverged. Never edit by hand.",
|
|
3
|
+
"files": {
|
|
4
|
+
"LICENSE": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
|
|
5
|
+
"NOTICE": "8414f424f7c1db19d89d74286fd2d6b1acdd9eedde910fa57a16d6835602e4bd",
|
|
6
|
+
"dispatch.md": "d127057198415f81b921c74e3234a47d5794a2af662eeadaf3dff3cc1739ebc8",
|
|
7
|
+
"docs_site/app.js": "52236c7904ba4628ae21410b6f41983d4608c99ef9e3f5cf285f2000ebfe4d77",
|
|
8
|
+
"docs_site/index.html": "2b64167470f754e46fa4bb4e73a3d2c34ee632d91fe2f17705a58d18b5018af5",
|
|
9
|
+
"docs_site/strings.js": "6f8720c156396bc6553b41a706502d6cbb59b1417c0ba5e5fb6d0f55f480abbb",
|
|
10
|
+
"docs_site/style.css": "851c0ff7c75efdcc481add7dae225fb6f83f03dcb5449f446e0d61bbfe4cb7e7",
|
|
11
|
+
"guides.md": "651c150ad3014edbbbc1908dfdca2404db2a877e3d0cbf5cc7921d9be31c187f",
|
|
12
|
+
"memory.md": "5907be5f99b18eb66ffda2c25815f4fa266fff4b3767f4c142d601b146271167",
|
|
13
|
+
"review.md": "c94618c02e34823ef29cb8d90d7288764dc3c72dc136905c8516e226385aa463",
|
|
14
|
+
"routing.md": "2f90556cb8516599b9fd6ebf0b5f61dffc107ff186a4c14aed0e483b82cf91d2",
|
|
15
|
+
"scripts/docs_site.py": "cc542da4b5702f986d89c6af06b15423c9a0d03168611caf3796a6eb850a9b93",
|
|
16
|
+
"scripts/entry_point.py": "4a560d4d41db989d3426182f1b24270cbb934b3db0f3246c700578a4ed93089b",
|
|
17
|
+
"scripts/knowledge.py": "ac50bffcbfd9c7d7c098e4a40995f66f18884202a5511d602961c9dcd9afa498",
|
|
18
|
+
"scripts/sdlc_core.py": "b66b10956685a31203b9f757e0c2d88c8f30e4fc9447ced14108627aafd668e5",
|
|
19
|
+
"scripts/shared_files.py": "bc3b311ca5c736070e9970428429226ca0a9186e010c265b556512b6df191e73",
|
|
20
|
+
"scripts/test_audit_refs.py": "a6f06e73f4c9469dd64bed283024b6b37294e6c26f171d8cbdbdfd03771a6884",
|
|
21
|
+
"scripts/test_chronology.py": "5cffc2711282452fe1148a95e46b0b31940809aed3a63950329d9b3e868d0cda",
|
|
22
|
+
"scripts/test_docs_root.py": "19753e0316c0df63ed321d24b411a9ff36eea2058421fd69fc57e507d75566d0",
|
|
23
|
+
"scripts/test_docs_site.py": "8ccfcdb638c5fcc1a557fd150c02840e91aa3d93df57963b97dbcaf882177965",
|
|
24
|
+
"scripts/test_domain_rules.py": "33542b5dc2bb77c05ece55cf0446065cc4226c763ef5e040aa77364f226937bb",
|
|
25
|
+
"scripts/test_drift.py": "c7b7121a8a308b0f3fe12c0c0c346444e632b7aaeff47dc8818b621e5c20cb38",
|
|
26
|
+
"scripts/test_merge_safety.py": "8b9241077bf7ecd6dd335907c640044ab38968ff4c9fdc464450e732cb77adf4",
|
|
27
|
+
"scripts/test_migrate.py": "c129823bf4feb0614acd975351632a6ae62d7896a0ef32724419a8e9335e3110",
|
|
28
|
+
"scripts/test_plan.py": "4c011a63aa834b55e9c5d13fb4ea76b131b742356001364b0a508435ba013e27",
|
|
29
|
+
"scripts/test_project_memory.py": "2cb85cef01161d881a11d84cfe9ebb72915104e8d1822e4de739f7d00ae6cd59",
|
|
30
|
+
"scripts/test_session_start.py": "203fa72b87fb263663ce7fcd737460530b75fd5e76e883f25cd849e6cca98275",
|
|
31
|
+
"scripts/test_skill_invariants.py": "17dbccaac688b75e04c44eaaf0ee5b78e85ce2aa4697fc7a52041f3175fe0735",
|
|
32
|
+
"vision.md": "a19a7d7d5044215b726b66f74e422c9cdedcc856c0a53151f20a6d9840b8f6c4"
|
|
33
|
+
}
|
|
34
|
+
}
|