@antoneeo/course-creator 0.1.0-beta.1 → 0.1.0-beta.13
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 +102 -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 +17 -5
- package/skills/course-creator/SKILL.md +8 -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/guides.md +7 -1
- package/skills/course-creator/memory.md +1 -1
- package/skills/course-creator/review.md +107 -13
- package/skills/course-creator/routing.md +4 -0
- package/skills/course-creator/scripts/course_check.py +132 -132
- package/skills/course-creator/scripts/docs_site.py +1448 -0
- package/skills/course-creator/scripts/knowledge.py +620 -62
- package/skills/course-creator/scripts/sdlc_core.py +958 -84
- package/skills/course-creator/scripts/shared_files.py +10 -0
- package/skills/course-creator/scripts/shared_manifest.json +23 -14
- package/skills/course-creator/templates.md +51 -8
- package/skills/course-creator/vision.md +308 -245
|
@@ -21,6 +21,8 @@ Commands:
|
|
|
21
21
|
stale list areas modified after the last analysis recorded in audit_plan.md (exit 1 if any)
|
|
22
22
|
mark record paths as ANALYZED with the current reference (git hash, else UTC timestamp)
|
|
23
23
|
gate PreToolUse hook: block writes on protected paths without an IN_PROGRESS ANALYSIS (exit 2)
|
|
24
|
+
verdict PreToolUse hook: refuse an edit when the session declared no guide-router
|
|
25
|
+
verdict, or never read a guide it names (JSON deny on stdout; always exit 0)
|
|
24
26
|
|
|
25
27
|
Hybrid/devPNT mode: pass --hybrid explicitly on check/stale (skips audit-plan
|
|
26
28
|
staleness, delegated to devPNT/KL) and on gate (also unlocks when an approved
|
|
@@ -89,6 +91,18 @@ ARCHITECT_PASS_EPOCH = "2026-07-28"
|
|
|
89
91
|
# row. Same grandfathering discipline as the pass above -- never nag work that
|
|
90
92
|
# predates the rule.
|
|
91
93
|
DESIGN_REVIEW_EPOCH = "2026-07-28"
|
|
94
|
+
# Chronology (F-073): an L3 ANALYSIS started, or an L3 HANDOFF updated, on/after
|
|
95
|
+
# this date is read for undated Diary text and a multi-step `next:`. Advisories,
|
|
96
|
+
# like the two gates above: a fixed epoch cannot protect a late upgrader, so a
|
|
97
|
+
# warning here would redden --strict CI on work written before the upgrade.
|
|
98
|
+
CHRONOLOGY_EPOCH = "2026-10-18"
|
|
99
|
+
# Guide check (F-078): an L3 code ANALYSIS started on/after this date owes a
|
|
100
|
+
# filled '## Guide check'. The day AFTER the rule was written, so the analyses
|
|
101
|
+
# opened that day by parallel units (which never saw the template) stay quiet.
|
|
102
|
+
GUIDE_CHECK_EPOCH = "2026-10-24"
|
|
103
|
+
NEXT_MAX_CHARS = 240 # this repository's longest `next:` was 214 at the epoch
|
|
104
|
+
ID_SCAN_MAX_REFS = 300 # A3 is skipped above it (one advisory says so, T9)
|
|
105
|
+
ID_SCAN_REFS_PER_CALL = 50 # keeps one git command line far below 32k chars
|
|
92
106
|
def review_log_rel():
|
|
93
107
|
return f"{docs_dir()}/audit/reviews/REVIEW_LOG.md"
|
|
94
108
|
# Component Map 'Where' refs: a dotted token counts as a path only with one of
|
|
@@ -388,8 +402,17 @@ def orient_docs():
|
|
|
388
402
|
("Guide router (when-to-consult)", f"{d}/reference/INDEX.md"),
|
|
389
403
|
("Last session handoff", f"{d}/audit/handoff.md"),
|
|
390
404
|
]
|
|
391
|
-
|
|
392
|
-
|
|
405
|
+
# F-075: the WHOLE output -- the knowledge layer's append included -- stays under
|
|
406
|
+
# Claude Code's 10,000-character hook cap. Above it the client saves the output
|
|
407
|
+
# to a file and injects a 2,000-character preview, and the agent never opens the
|
|
408
|
+
# file: the router printed third, after README and INDEX, never reached a session.
|
|
409
|
+
ORIENT_MAX_TOTAL_CHARS = 9500 # whole output (P-TM T2)
|
|
410
|
+
ORIENT_APPEND_RESERVE = 2000 # left for the knowledge layer's append, which cuts itself to it
|
|
411
|
+
ORIENT_ROUTER_CHARS = 3000 # ~20 guides; the full router stays one read away
|
|
412
|
+
ORIENT_REGISTRY_CHARS = 2600 # registry rows + project-wide notes
|
|
413
|
+
ORIENT_TRIAGE_LINE = ("Triage every request (Rule Zero): L1 trivial - L2 small - L3 significant "
|
|
414
|
+
"- Spike. Declare the level with the guide-router verdict (a guide below, "
|
|
415
|
+
"or \"no match\"). When in doubt, pick the higher level.")
|
|
393
416
|
|
|
394
417
|
|
|
395
418
|
# ----------------------------------------------------------------- utilities
|
|
@@ -516,6 +539,41 @@ def has_etdd_shadow(root):
|
|
|
516
539
|
return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))
|
|
517
540
|
|
|
518
541
|
|
|
542
|
+
def newest_shadow(solutions_dir, doc_key):
|
|
543
|
+
"""The newest devPNT shadow of `doc_key` in `solutions_dir`, or None.
|
|
544
|
+
Only `SHADOW_<doc_key>_v<N>[.<N>...].md` counts -- the exact key, so
|
|
545
|
+
`<doc_key>_extra` is another key -- and versions compare number by number
|
|
546
|
+
(v1.10 above v1.9, v1.0 above v1). Names outside that rule are ignored.
|
|
547
|
+
F-081: the Hybrid target of a claim marker; reusable by any consumer."""
|
|
548
|
+
if not Path(solutions_dir).is_dir():
|
|
549
|
+
return None
|
|
550
|
+
best = None
|
|
551
|
+
for p in Path(solutions_dir).iterdir():
|
|
552
|
+
v = _shadow_version(p.name, doc_key)
|
|
553
|
+
if v is not None and p.is_file():
|
|
554
|
+
if best is None or v > best[0]:
|
|
555
|
+
best = (v, p)
|
|
556
|
+
return best[1] if best else None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _shadow_version(name, doc_key):
|
|
560
|
+
"""The version tuple of `SHADOW_<doc_key>_v<N>[.<N>...].md`, else None."""
|
|
561
|
+
m = re.match(r"^SHADOW_%s_v(\d+(?:\.\d+)*)\.md$" % re.escape(doc_key), name)
|
|
562
|
+
return tuple(int(x) for x in m.group(1).split(".")) if m else None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def stray_shadow(solutions_dir, doc_key):
|
|
566
|
+
"""A file of the same exact key that `newest_shadow` does not read
|
|
567
|
+
(`SHADOW_<doc_key>.md`, `..._v1.2a.md`), or None -- the name a user needs
|
|
568
|
+
to see when no shadow resolves. A longer key is never a stray."""
|
|
569
|
+
if not Path(solutions_dir).is_dir():
|
|
570
|
+
return None
|
|
571
|
+
rx = re.compile(r"^SHADOW_%s(?:_v[^_]*)?\.md$" % re.escape(doc_key))
|
|
572
|
+
return next((p.name for p in sorted(Path(solutions_dir).iterdir())
|
|
573
|
+
if rx.match(p.name) and _shadow_version(p.name, doc_key) is None),
|
|
574
|
+
None)
|
|
575
|
+
|
|
576
|
+
|
|
519
577
|
def iter_files(target):
|
|
520
578
|
if target.is_file():
|
|
521
579
|
yield target
|
|
@@ -834,6 +892,10 @@ def build_registry(root):
|
|
|
834
892
|
return ""
|
|
835
893
|
stamp = max(str(m.get("updated") or m.get("since") or "").strip() or "0000-00-00"
|
|
836
894
|
for _p, m in rows)
|
|
895
|
+
return _render_registry(root, rows, stamp)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _render_registry(root, rows, stamp):
|
|
837
899
|
lines = ["# Handoff — workstream registry",
|
|
838
900
|
f"Date: {stamp} (UTC)",
|
|
839
901
|
"",
|
|
@@ -858,15 +920,44 @@ def parse_registry_rows(text):
|
|
|
858
920
|
"""Workstream ids in a registry table, hand-written or generated."""
|
|
859
921
|
ids = []
|
|
860
922
|
for line in text.splitlines():
|
|
861
|
-
|
|
862
|
-
if not
|
|
923
|
+
cells = _table_cells(line)
|
|
924
|
+
if not cells:
|
|
863
925
|
continue
|
|
864
|
-
first =
|
|
926
|
+
first = cells[0]
|
|
865
927
|
if first and first.lower() != "workstream":
|
|
866
928
|
ids.append(first)
|
|
867
929
|
return ids
|
|
868
930
|
|
|
869
931
|
|
|
932
|
+
def is_generated_registry(text):
|
|
933
|
+
# "Ours" = written by ANY family entry point: the header is WRITTEN with
|
|
934
|
+
# entry_script(), so recognition must not hard-code one distribution's
|
|
935
|
+
# script name (a registry generated by mkt_check.py is just as generated).
|
|
936
|
+
return bool(re.search(r"GENERATED by \S+ index - do not edit by hand", text))
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def generated_registry_rows(text):
|
|
940
|
+
"""Workstream rows of a generated registry, ignoring the project notes
|
|
941
|
+
(free text that may carry tables of its own)."""
|
|
942
|
+
return parse_registry_rows(text.split("\n## Project-wide notes", 1)[0])
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def build_empty_registry(root, existing):
|
|
946
|
+
"""The generated registry once the last source is gone (F1, found by F-068).
|
|
947
|
+
|
|
948
|
+
Only for a registry that is already ours: a hand-written handoff with no
|
|
949
|
+
sources is a project that never converted, and stays untouched. The Date is
|
|
950
|
+
kept while the view is already empty -- re-running index on a clean tree
|
|
951
|
+
must not change it -- and becomes today's when rows are being dropped:
|
|
952
|
+
there is no source left whose `updated:` could date the closure."""
|
|
953
|
+
m = re.search(r"Date:\s*(\d{4}-\d{2}-\d{2})", existing)
|
|
954
|
+
if m and not generated_registry_rows(existing):
|
|
955
|
+
stamp = m.group(1)
|
|
956
|
+
else:
|
|
957
|
+
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
958
|
+
return _render_registry(root, [], stamp)
|
|
959
|
+
|
|
960
|
+
|
|
870
961
|
def registry_conversion_blockers(root):
|
|
871
962
|
"""What stops `index` from writing over a hand-written handoff.md.
|
|
872
963
|
|
|
@@ -879,10 +970,7 @@ def registry_conversion_blockers(root):
|
|
|
879
970
|
if not hand.is_file():
|
|
880
971
|
return []
|
|
881
972
|
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):
|
|
973
|
+
if is_generated_registry(text):
|
|
886
974
|
return []
|
|
887
975
|
blockers = []
|
|
888
976
|
known = {str(m.get("workstream")).strip() for _p, m in list_workstreams(root)}
|
|
@@ -1015,10 +1103,21 @@ def cmd_index(root):
|
|
|
1015
1103
|
def rc_registry(root):
|
|
1016
1104
|
"""Write the generated workstream registry, or refuse and say why (F-028)."""
|
|
1017
1105
|
ws = list_workstreams(root)
|
|
1106
|
+
hand = ai_path(root, "audit", "handoff.md")
|
|
1018
1107
|
if not ws:
|
|
1019
|
-
|
|
1108
|
+
# No sources: a hand-written handoff is untouched; a generated one is
|
|
1109
|
+
# emptied, or the last closed workstream would stay listed forever.
|
|
1110
|
+
if not hand.is_file():
|
|
1111
|
+
return 0
|
|
1112
|
+
existing = read_text(hand)
|
|
1113
|
+
if not is_generated_registry(existing):
|
|
1114
|
+
return 0
|
|
1115
|
+
empty = build_empty_registry(root, existing)
|
|
1116
|
+
if norm_text(existing) != norm_text(empty):
|
|
1117
|
+
hand.write_text(empty, encoding="utf-8")
|
|
1118
|
+
print(f"[ok] workstream registry emptied (no open workstreams): {hand}")
|
|
1119
|
+
return 0
|
|
1020
1120
|
blockers = registry_conversion_blockers(root)
|
|
1021
|
-
hand = ai_path(root, "audit", "handoff.md")
|
|
1022
1121
|
if blockers:
|
|
1023
1122
|
print(f"[ERROR] {docs_dir()}/audit/handoff.md NOT regenerated -- it still holds "
|
|
1024
1123
|
"state no source accounts for:")
|
|
@@ -1060,6 +1159,317 @@ def section_body(text, aliases):
|
|
|
1060
1159
|
return None
|
|
1061
1160
|
|
|
1062
1161
|
|
|
1162
|
+
def section_lines(text, aliases):
|
|
1163
|
+
"""(heading_line, body) for the first matching `## ` section, or None: the
|
|
1164
|
+
body NOT stripped, so body line n is file line heading_line + n, and an
|
|
1165
|
+
indented first line keeps its indentation. The readers below use it; a
|
|
1166
|
+
stripped body would shift every line number and move ` - DATE` to column 0."""
|
|
1167
|
+
lines = (text or "").splitlines()
|
|
1168
|
+
for i, line in enumerate(lines):
|
|
1169
|
+
if any(line.startswith(a) for a in aliases):
|
|
1170
|
+
body = []
|
|
1171
|
+
for nxt in lines[i + 1:]:
|
|
1172
|
+
if nxt.startswith("## "):
|
|
1173
|
+
break
|
|
1174
|
+
body.append(nxt)
|
|
1175
|
+
return i + 1, "\n".join(body)
|
|
1176
|
+
return None
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
# ---------------------------------------------------------------- chronology
|
|
1180
|
+
# One grammar for a dated entry (F-073), read by the validator and by any
|
|
1181
|
+
# consumer that lists events (F-072's chronology): both call these readers, so
|
|
1182
|
+
# they cannot disagree on what an event is. The readers return openers only --
|
|
1183
|
+
# date, label, line -- and format nothing for display.
|
|
1184
|
+
|
|
1185
|
+
DIARY_SECTION = next(s for s in ANALYSIS_SECTIONS if s[0] == "## Diary")
|
|
1186
|
+
_ISO = r"\d{4}-\d{2}-\d{2}"
|
|
1187
|
+
# At column 0: optional list marker, optional `###`/`####`, optional bold, then
|
|
1188
|
+
# the ISO date and a word boundary. The date FIRST is the rule; markup is
|
|
1189
|
+
# tolerated so the dated forms already written stay readable with no migration.
|
|
1190
|
+
ENTRY_OPENER = re.compile(r"^(?:([-*]) |(#{3,4}) )?(\*\*)?(" + _ISO + r")\b(.*)$")
|
|
1191
|
+
_LABEL_SEP = re.compile(r"^(?:\*\*)?\s*(?:[—–:-])?\s*")
|
|
1192
|
+
_REQUEST_HEADING = re.compile(r"^### (R\d+) [—–-] (" + _ISO + r") [—–-] (.+)$")
|
|
1193
|
+
_ADR_BODY_DATE = re.compile(r"^\**Date:\**\s*(" + _ISO + r")\b", re.M)
|
|
1194
|
+
_ADR_NAME_DATE = re.compile(r"^ADR_(" + _ISO + r")_", re.I)
|
|
1195
|
+
UNIT_ID_RE = re.compile(r"\b([A-Z]-\d{3,})\b")
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _entry_label(bold, rest):
|
|
1199
|
+
rest = _LABEL_SEP.sub("", rest, count=1)
|
|
1200
|
+
if bold and "**" in rest:
|
|
1201
|
+
rest = rest.split("**", 1)[0]
|
|
1202
|
+
else:
|
|
1203
|
+
rest = re.split(r"(?<=[.!?])\s", rest, maxsplit=1)[0]
|
|
1204
|
+
return rest.strip(" .:—–-*")[:120]
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _opener_at(lines, i):
|
|
1208
|
+
"""The match when lines[i] opens an entry. It must also START A BLOCK -- the
|
|
1209
|
+
line above is blank, absent or a heading, or the line is itself a list item
|
|
1210
|
+
or a heading -- so a hard-wrapped prose line that happens to begin with a
|
|
1211
|
+
date is never an event. (A separator after the date is NOT required: real
|
|
1212
|
+
openers read `DATE (qualifier)` and `DATE word`.)"""
|
|
1213
|
+
m = ENTRY_OPENER.match(lines[i])
|
|
1214
|
+
if not m:
|
|
1215
|
+
return None
|
|
1216
|
+
prev = lines[i - 1] if i else ""
|
|
1217
|
+
if not prev.strip() or prev.startswith("#") or m.group(1) or m.group(2):
|
|
1218
|
+
return m
|
|
1219
|
+
return None
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def malformed_openers(body):
|
|
1223
|
+
"""[(line, text)] -- dated openers whose bold does not pair: `**DATE …` with no
|
|
1224
|
+
closing `**` on the line, or a bare `DATE …` that closes one. Either way the
|
|
1225
|
+
chronology shows a label running into the entry's body (field case 2026-10-19:
|
|
1226
|
+
two release notes in project_notes.md)."""
|
|
1227
|
+
lines = _blank_comments(body).splitlines()
|
|
1228
|
+
out = []
|
|
1229
|
+
for i in range(len(lines)):
|
|
1230
|
+
m = _opener_at(lines, i)
|
|
1231
|
+
if m and bool(m.group(3)) != ("**" in m.group(5)):
|
|
1232
|
+
out.append((i + 1, lines[i]))
|
|
1233
|
+
return out
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def entry_openers(body):
|
|
1237
|
+
"""[(date, label, line)] -- one per dated entry opener; line is 1-based
|
|
1238
|
+
within `body`. An undated paragraph belongs to the entry above it. Comments
|
|
1239
|
+
are blanked first, exactly as `undated_diary_lines` does, so an entry
|
|
1240
|
+
written under the template's comment is an event to both."""
|
|
1241
|
+
lines = _blank_comments(body).splitlines()
|
|
1242
|
+
out = []
|
|
1243
|
+
for i in range(len(lines)):
|
|
1244
|
+
m = _opener_at(lines, i)
|
|
1245
|
+
if m:
|
|
1246
|
+
out.append((m.group(4), _entry_label(m.group(3), m.group(5)), i + 1))
|
|
1247
|
+
return out
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _blank_comments(text):
|
|
1251
|
+
# comments become blank lines, so line numbers still point at the file's lines
|
|
1252
|
+
return re.sub(r"<!--.*?-->", lambda m: "\n" * m.group(0).count("\n"), text or "",
|
|
1253
|
+
flags=re.S)
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def undated_diary_lines(body):
|
|
1257
|
+
"""[(line, text)] a Diary body cannot date: text before the first opener,
|
|
1258
|
+
and any `### ` heading that is not an opener. `####` and deeper headings are
|
|
1259
|
+
sub-structure of an entry and stay legal. Comments are ignored."""
|
|
1260
|
+
lines = _blank_comments(body).splitlines()
|
|
1261
|
+
out, seen_opener = [], False
|
|
1262
|
+
for i, line in enumerate(lines):
|
|
1263
|
+
if _opener_at(lines, i):
|
|
1264
|
+
seen_opener = True
|
|
1265
|
+
elif line.startswith("### "):
|
|
1266
|
+
out.append((i + 1, line.strip()))
|
|
1267
|
+
elif not seen_opener and line.strip():
|
|
1268
|
+
out.append((i + 1, line.strip()))
|
|
1269
|
+
return out
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def request_entries(text):
|
|
1273
|
+
"""(entries, unparsed) for a request record: entries are
|
|
1274
|
+
[(rid, date, label, line)]; a `### ` heading that does not match
|
|
1275
|
+
`### R<n> — YYYY-MM-DD — <label>` is returned in unparsed as (line, text),
|
|
1276
|
+
never dropped."""
|
|
1277
|
+
entries, unparsed = [], []
|
|
1278
|
+
for i, line in enumerate((text or "").splitlines()):
|
|
1279
|
+
if not line.startswith("### "):
|
|
1280
|
+
continue
|
|
1281
|
+
m = _REQUEST_HEADING.match(line.rstrip())
|
|
1282
|
+
if m:
|
|
1283
|
+
entries.append((m.group(1), m.group(2), m.group(3).strip(), i + 1))
|
|
1284
|
+
else:
|
|
1285
|
+
unparsed.append((i + 1, line.rstrip()))
|
|
1286
|
+
return entries, unparsed
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def adr_date(name, text):
|
|
1290
|
+
"""An ADR's date: `**Date:** YYYY-MM-DD` in the body, else the
|
|
1291
|
+
`ADR_YYYY-MM-DD_*` file name, else None."""
|
|
1292
|
+
m = _ADR_BODY_DATE.search(text or "") or _ADR_NAME_DATE.match(name or "")
|
|
1293
|
+
return m.group(1) if m else None
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _meta_date(meta, key):
|
|
1297
|
+
return parse_iso(str(meta.get(key) or "").strip().strip("'\"")[:10])
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def chronology_advisories(root):
|
|
1301
|
+
"""A1 (undated Diary text) and A2 (`next:` is not one step), as advisory
|
|
1302
|
+
strings. Gated on `level: L3` and on a date the file carries -- never an
|
|
1303
|
+
mtime -- so L1/L2 work and anything written before the epoch see nothing."""
|
|
1304
|
+
epoch = parse_iso(CHRONOLOGY_EPOCH)
|
|
1305
|
+
out = []
|
|
1306
|
+
for p, meta, text in list_analyses(root):
|
|
1307
|
+
started = _meta_date(meta, "start_date")
|
|
1308
|
+
if str(meta.get("level", "")).upper() != "L3" or not started or started < epoch:
|
|
1309
|
+
continue
|
|
1310
|
+
section = section_lines(text, DIARY_SECTION)
|
|
1311
|
+
if section is None:
|
|
1312
|
+
continue
|
|
1313
|
+
head, body = section
|
|
1314
|
+
rel = "solutions/" + p.name
|
|
1315
|
+
lines = undated_diary_lines(body)
|
|
1316
|
+
preface = [(n, t) for n, t in lines if not t.startswith("### ")]
|
|
1317
|
+
if preface:
|
|
1318
|
+
out.append(f"{rel}: line {head + preface[0][0]}: Diary text '{preface[0][1][:60]}' "
|
|
1319
|
+
"comes before the first dated entry -- open entries with "
|
|
1320
|
+
"**YYYY-MM-DD — label.**, and write the current state as the latest "
|
|
1321
|
+
"dated entry")
|
|
1322
|
+
for n, t in lines:
|
|
1323
|
+
if t.startswith("### "):
|
|
1324
|
+
out.append(f"{rel}: line {head + n}: Diary heading '{t[:60]}' is not a dated "
|
|
1325
|
+
"entry -- open entries with **YYYY-MM-DD — label.**")
|
|
1326
|
+
for p, meta in list_workstreams(root):
|
|
1327
|
+
updated = _meta_date(meta, "updated")
|
|
1328
|
+
if str(meta.get("level", "")).upper() != "L3" or not updated or updated < epoch:
|
|
1329
|
+
continue
|
|
1330
|
+
nxt = str(meta.get("next") or "").strip()
|
|
1331
|
+
if len(nxt) > NEXT_MAX_CHARS:
|
|
1332
|
+
out.append(f"audit/{p.name}: next: is {len(nxt)} chars (> {NEXT_MAX_CHARS}): one "
|
|
1333
|
+
"next step here, the state goes to the Diary as a dated entry")
|
|
1334
|
+
notes = ai_path(root, "audit", PROJECT_NOTES)
|
|
1335
|
+
if notes.is_file():
|
|
1336
|
+
for n, t in malformed_openers(read_text(notes)):
|
|
1337
|
+
out.append(f"audit/{PROJECT_NOTES}: line {n}: dated entry '{t[:60]}' has unpaired "
|
|
1338
|
+
"bold -- open entries with **YYYY-MM-DD — label.**")
|
|
1339
|
+
return out
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _open_units(root):
|
|
1343
|
+
"""{id: {analysis name}} for the tree's open L3 units: an L3 ANALYSIS that is
|
|
1344
|
+
PLANNED or IN_PROGRESS, or a request record (L3 only, r26) whose `analysis:`
|
|
1345
|
+
is absent from the tree or names such an open ANALYSIS. A closed unit's
|
|
1346
|
+
record, kept forever, does not count."""
|
|
1347
|
+
units, present, open_names = {}, set(), set()
|
|
1348
|
+
for p, meta, _ in list_analyses(root):
|
|
1349
|
+
present.add(p.name)
|
|
1350
|
+
if (str(meta.get("level", "")).upper() == "L3"
|
|
1351
|
+
and meta.get("status") in ("PLANNED", "IN_PROGRESS")):
|
|
1352
|
+
m = UNIT_ID_RE.search(str(meta.get("id") or ""))
|
|
1353
|
+
if m:
|
|
1354
|
+
open_names.add(p.name)
|
|
1355
|
+
units.setdefault(m.group(1), set()).add(p.name)
|
|
1356
|
+
req = ai_path(root, "audit", "requests")
|
|
1357
|
+
if req.is_dir():
|
|
1358
|
+
for p in sorted(req.glob("REQUEST_*.md")):
|
|
1359
|
+
meta = load_frontmatter(read_text(p).splitlines())
|
|
1360
|
+
name = str(meta.get("analysis") or "").strip().strip("'\"`")
|
|
1361
|
+
m = UNIT_ID_RE.search(str(meta.get("unit") or meta.get("id") or ""))
|
|
1362
|
+
if m and name and (name not in present or name in open_names):
|
|
1363
|
+
units.setdefault(m.group(1), set()).add(name)
|
|
1364
|
+
return units
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _git_out(root, *args, timeout=30):
|
|
1368
|
+
r = subprocess.run(["git", *args], cwd=str(root), capture_output=True, text=True,
|
|
1369
|
+
encoding="utf-8", errors="replace", timeout=timeout)
|
|
1370
|
+
return r.stdout if r.returncode == 0 else None
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def ids_on_other_refs(root, max_refs=ID_SCAN_MAX_REFS):
|
|
1374
|
+
"""[(id, mine, theirs, [refs])] -- an open unit's id that another ref (heads,
|
|
1375
|
+
remotes, tags) uses for a DIFFERENT analysis. Identity is the ANALYSIS file
|
|
1376
|
+
name, declared by the ANALYSIS or announced by a request record's
|
|
1377
|
+
`analysis:`. One `git grep` over local refs, fixed arguments only (T8);
|
|
1378
|
+
as fresh as the last fetch. [] with no open L3 unit or no git; None when
|
|
1379
|
+
skipped -- above `max_refs` refs, or git failed or timed out -- so the
|
|
1380
|
+
caller can say the check did not run instead of reporting it clean."""
|
|
1381
|
+
mine = _open_units(root)
|
|
1382
|
+
if not mine or not git_available(root):
|
|
1383
|
+
return []
|
|
1384
|
+
try:
|
|
1385
|
+
head = (_git_out(root, "rev-parse", "HEAD") or "").strip()
|
|
1386
|
+
listing = _git_out(root, "for-each-ref",
|
|
1387
|
+
"--format=%(objectname) %(objecttype) %(*objecttype) %(refname)",
|
|
1388
|
+
"refs/heads", "refs/remotes", "refs/tags")
|
|
1389
|
+
if listing is None:
|
|
1390
|
+
return None
|
|
1391
|
+
refs = []
|
|
1392
|
+
for line in listing.split("\n"):
|
|
1393
|
+
parts = line.split(" ")
|
|
1394
|
+
if len(parts) == 3: # %(*objecttype) empty: not a tag
|
|
1395
|
+
parts.insert(2, "")
|
|
1396
|
+
if len(parts) != 4:
|
|
1397
|
+
continue
|
|
1398
|
+
sha, kind, peeled, ref = parts
|
|
1399
|
+
# only refs that resolve to a commit can be grepped (a tag on a blob
|
|
1400
|
+
# would fail the whole batch); the tree's own commit is not "another
|
|
1401
|
+
# ref"; symbolic HEADs repeat a branch
|
|
1402
|
+
if "commit" not in (kind, peeled) or ref.endswith("/HEAD") or sha == head:
|
|
1403
|
+
continue
|
|
1404
|
+
refs.append(ref)
|
|
1405
|
+
if not refs:
|
|
1406
|
+
return []
|
|
1407
|
+
if len(refs) > max_refs:
|
|
1408
|
+
return None
|
|
1409
|
+
# Pathspecs are relative to cwd=root, so a docs root inside a monorepo
|
|
1410
|
+
# subdirectory is found. -z keeps a path with ':' or non-ASCII intact
|
|
1411
|
+
# (quotePath off); -n plus the fence lines confine hits to frontmatter.
|
|
1412
|
+
docs = docs_dir()
|
|
1413
|
+
chunks = []
|
|
1414
|
+
for i in range(0, len(refs), ID_SCAN_REFS_PER_CALL): # command-line length
|
|
1415
|
+
r = subprocess.run(
|
|
1416
|
+
["git", "-c", "core.quotePath=false", "grep", "-z", "-n", "-I", "-E",
|
|
1417
|
+
r"^(id|unit|analysis):|^---\s*$", *refs[i:i + ID_SCAN_REFS_PER_CALL],
|
|
1418
|
+
"--", f"{docs}/solutions/ANALYSIS_*.md", f"{docs}/audit/requests/REQUEST_*.md"],
|
|
1419
|
+
cwd=str(root), capture_output=True, text=True, encoding="utf-8",
|
|
1420
|
+
errors="replace", timeout=60)
|
|
1421
|
+
if r.returncode not in (0, 1): # 1 = no match
|
|
1422
|
+
return None
|
|
1423
|
+
chunks.append(r.stdout)
|
|
1424
|
+
except Exception:
|
|
1425
|
+
return None
|
|
1426
|
+
per = {}
|
|
1427
|
+
for line in "".join(chunks).split("\n"): # not splitlines: \x1c.. are content
|
|
1428
|
+
parts = line.split("\0")
|
|
1429
|
+
if len(parts) != 3 or ":" not in parts[0] or not parts[1].isdigit():
|
|
1430
|
+
continue
|
|
1431
|
+
ref, path = parts[0].split(":", 1) # a ref name cannot hold ':'
|
|
1432
|
+
content = parts[2].rstrip("\r")
|
|
1433
|
+
st = per.setdefault((ref, path), {"fences": 0, "kv": {}})
|
|
1434
|
+
if int(parts[1]) == 1 and content.strip() == "---":
|
|
1435
|
+
st["fences"] = 1 # frontmatter opens on line 1
|
|
1436
|
+
elif st["fences"] == 1 and content.strip() == "---":
|
|
1437
|
+
st["fences"] = 2 # ...and closes here
|
|
1438
|
+
elif st["fences"] == 1:
|
|
1439
|
+
key, _, val = content.partition(":")
|
|
1440
|
+
st["kv"].setdefault(key, val.strip())
|
|
1441
|
+
found = {}
|
|
1442
|
+
for (ref, path), st in per.items():
|
|
1443
|
+
kv = st["kv"]
|
|
1444
|
+
fname = path.replace("\\", "/").rsplit("/", 1)[-1]
|
|
1445
|
+
name = fname if fname.startswith("ANALYSIS_") else kv.get("analysis", "").strip("'\"`")
|
|
1446
|
+
m = UNIT_ID_RE.search(kv.get("id") or kv.get("unit") or "")
|
|
1447
|
+
if not (m and name) or m.group(1) not in mine or name in mine[m.group(1)]:
|
|
1448
|
+
continue
|
|
1449
|
+
short = re.sub(r"^refs/(heads|remotes|tags)/", "", ref)
|
|
1450
|
+
found.setdefault((m.group(1), name), [])
|
|
1451
|
+
if short not in found[(m.group(1), name)]:
|
|
1452
|
+
found[(m.group(1), name)].append(short)
|
|
1453
|
+
return sorted((fid, ", ".join(sorted(mine[fid])), theirs, sorted(refs_))
|
|
1454
|
+
for (fid, theirs), refs_ in found.items())
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def id_advisories(root):
|
|
1458
|
+
"""A3 as advisory strings, for every entry point's `check`."""
|
|
1459
|
+
hits = ids_on_other_refs(root)
|
|
1460
|
+
if hits is None:
|
|
1461
|
+
return [f"id check skipped (more than {ID_SCAN_MAX_REFS} refs, or git grep failed "
|
|
1462
|
+
"or timed out) -- choose a new unit's id after fetching, as the first id "
|
|
1463
|
+
"free on every ref"]
|
|
1464
|
+
out = []
|
|
1465
|
+
for fid, mine, theirs, refs in hits:
|
|
1466
|
+
more = f" (+{len(refs) - 1} more)" if len(refs) > 1 else ""
|
|
1467
|
+
out.append(f"id {fid} ({mine}) is also used by {theirs} on {refs[0]}{more}: choose "
|
|
1468
|
+
"the first id free on every ref -- if this is the same unit under its "
|
|
1469
|
+
"old name, ignore it")
|
|
1470
|
+
return out
|
|
1471
|
+
|
|
1472
|
+
|
|
1063
1473
|
def design_review_due(meta):
|
|
1064
1474
|
"""True when an L3 ANALYSIS owes a design-review row (review.md moment 1):
|
|
1065
1475
|
implementation has started or finished, and it began on/after the gate
|
|
@@ -1113,10 +1523,30 @@ def has_ledger_heading(text):
|
|
|
1113
1523
|
opens a comment at the start of a line -- nuking to EOF on an inline
|
|
1114
1524
|
mention (or an unclosed example inside a fence) made a document that
|
|
1115
1525
|
HAS its ledger get told it has none."""
|
|
1526
|
+
return bool(re.search(r"^##[ \t]+Capability Ledger[ \t]*$", strip_non_prose(text), re.M))
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def strip_non_prose(text):
|
|
1530
|
+
"""Text without fenced code blocks and HTML comments (see has_ledger_heading)."""
|
|
1116
1531
|
stripped = re.sub(r"^(```|~~~).*?^\1", "", text, flags=re.M | re.S)
|
|
1117
1532
|
stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.S)
|
|
1118
|
-
|
|
1119
|
-
|
|
1533
|
+
return re.sub(r"^[ \t]*<!--(?!.*?-->).*\Z", "", stripped, flags=re.M | re.S)
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def has_guide_check(text):
|
|
1537
|
+
"""True when a real '## Guide check' heading carries an answer: the template's
|
|
1538
|
+
comment alone answers nothing, so a copied-but-unfilled section still owes it."""
|
|
1539
|
+
m = re.search(r"^##[ \t]+Guide check[ \t]*$(.*?)(?=^#{1,2}[ \t]|\Z)",
|
|
1540
|
+
strip_non_prose(text), re.M | re.S)
|
|
1541
|
+
return bool(m and m.group(1).strip())
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def guide_check_due(meta, domain):
|
|
1545
|
+
"""True when an ANALYSIS owes the Phase-3 guide question (SKILL.md): a code-lens
|
|
1546
|
+
L3 started on/after GUIDE_CHECK_EPOCH. Same grandfathering as ledger_due."""
|
|
1547
|
+
return domain == "code" and ledger_due(meta) and (
|
|
1548
|
+
parse_iso((meta.get("start_date") or "").strip().strip("'\""))
|
|
1549
|
+
>= parse_iso(GUIDE_CHECK_EPOCH))
|
|
1120
1550
|
|
|
1121
1551
|
|
|
1122
1552
|
def ledger_due(meta):
|
|
@@ -1354,10 +1784,10 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1354
1784
|
"doing the work it triggers")
|
|
1355
1785
|
# comment-stripped, anchored: a '<!-- TODO: the ## Capability Ledger -->'
|
|
1356
1786
|
# must not read as the section being present
|
|
1357
|
-
# Hybrid: the design lives in devPNT and
|
|
1358
|
-
#
|
|
1359
|
-
#
|
|
1360
|
-
#
|
|
1787
|
+
# Hybrid: the design lives in devPNT, and the agent review of it (the
|
|
1788
|
+
# same review.md review, run before the proposal) logs a row keyed on
|
|
1789
|
+
# its e_isp_/e_tdd_ doc_key, not on this filename -- so firing here
|
|
1790
|
+
# would be a permanent, unfixable false positive.
|
|
1361
1791
|
if not hybrid and design_review_due(meta) and not review_logged(root, p.name):
|
|
1362
1792
|
advisories.append(f"{rel}: L3 in implementation with no design-review row in "
|
|
1363
1793
|
f"{review_log_rel()} -- the design was reviewed by nobody but its "
|
|
@@ -1365,6 +1795,10 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1365
1795
|
if ledger_due(meta) and not has_ledger_heading(text):
|
|
1366
1796
|
advisories.append(f"{rel}: L3 without '## Capability Ledger' -- the architect pass "
|
|
1367
1797
|
"left no record (architect.md); run it before the Impact")
|
|
1798
|
+
if guide_check_due(meta, domain) and not has_guide_check(text):
|
|
1799
|
+
advisories.append(f"{rel}: L3 without a filled '## Guide check' -- does a CURRENT "
|
|
1800
|
+
"guide cover what this touches, and did understanding it force "
|
|
1801
|
+
"a model of a complex component no guide covers (guides.md, section 1)?")
|
|
1368
1802
|
|
|
1369
1803
|
# Generated index aligned
|
|
1370
1804
|
hist = ai / "strategic" / "features_history.md"
|
|
@@ -1496,6 +1930,12 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1496
1930
|
"workstream — decide which file owns it")
|
|
1497
1931
|
else:
|
|
1498
1932
|
seen[wid] = p.name
|
|
1933
|
+
elif hand.is_file():
|
|
1934
|
+
text = read_text(hand)
|
|
1935
|
+
if is_generated_registry(text) and generated_registry_rows(text):
|
|
1936
|
+
errors.append(f"{docs_dir()}/audit/handoff.md lists workstreams no HANDOFF_*.md "
|
|
1937
|
+
"source exists for any more: run 'sdlc_check.py index' to drop "
|
|
1938
|
+
"the closed rows")
|
|
1499
1939
|
if hand.is_file():
|
|
1500
1940
|
m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
|
|
1501
1941
|
if not m:
|
|
@@ -1509,6 +1949,7 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1509
1949
|
except ValueError:
|
|
1510
1950
|
warnings.append("audit/handoff.md: date not parseable")
|
|
1511
1951
|
|
|
1952
|
+
advisories.extend(chronology_advisories(root)) # A1, A2 (F-073)
|
|
1512
1953
|
for a in advisories:
|
|
1513
1954
|
print(f"[note] {a}")
|
|
1514
1955
|
for w in warnings:
|
|
@@ -1653,7 +2094,8 @@ def cmd_stale(root, hybrid=False):
|
|
|
1653
2094
|
except ValueError: # symlink out of the tree: report absolute, never crash
|
|
1654
2095
|
name = str(fp)
|
|
1655
2096
|
changed.append(name)
|
|
1656
|
-
|
|
2097
|
+
docs_rel = docs_root_rel(root)
|
|
2098
|
+
changed = [c for c in changed if c != plan_rel and not _in_docs_root(c, docs_rel)]
|
|
1657
2099
|
if changed:
|
|
1658
2100
|
stale.append((rel, changed))
|
|
1659
2101
|
|
|
@@ -1674,9 +2116,54 @@ def cmd_stale(root, hybrid=False):
|
|
|
1674
2116
|
return 1 # stale areas dominate: rc already implied
|
|
1675
2117
|
|
|
1676
2118
|
|
|
1677
|
-
def
|
|
2119
|
+
def add_dated_note(root, note, day=None):
|
|
2120
|
+
"""Write `**DAY — note.**` into audit/project_notes.md, above the newest dated
|
|
2121
|
+
entry (below any undated preface), so the note opens with the shared grammar and
|
|
2122
|
+
the chronology reads it. The day defaults to today (local)."""
|
|
2123
|
+
import datetime as _dt
|
|
2124
|
+
day = day or _dt.date.today().isoformat()
|
|
2125
|
+
entry = "**%s — %s.**" % (day, note.strip().rstrip("."))
|
|
2126
|
+
f = ai_path(root, "audit", PROJECT_NOTES)
|
|
2127
|
+
if not f.is_file():
|
|
2128
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
2129
|
+
f.write_text(entry + "\n", encoding="utf-8")
|
|
2130
|
+
return entry
|
|
2131
|
+
raw = f.read_bytes()
|
|
2132
|
+
crlf = b"\r\n" in raw
|
|
2133
|
+
lines = raw.decode("utf-8", errors="replace").replace("\r\n", "\n").split("\n")
|
|
2134
|
+
at = next((i for i in range(len(lines)) if _opener_at(lines, i)), None)
|
|
2135
|
+
if at is None:
|
|
2136
|
+
while lines and not lines[-1].strip():
|
|
2137
|
+
lines.pop()
|
|
2138
|
+
lines += ["", entry, ""] if lines else [entry, ""]
|
|
2139
|
+
else:
|
|
2140
|
+
lines[at:at] = [entry, ""]
|
|
2141
|
+
text = "\n".join(lines)
|
|
2142
|
+
f.write_bytes((text.replace("\n", "\r\n") if crlf else text).encode("utf-8"))
|
|
2143
|
+
return entry
|
|
2144
|
+
|
|
2145
|
+
|
|
2146
|
+
def docs_root_rel(root):
|
|
2147
|
+
"""The docs root (ai_docs/, mkt_docs/ ...) relative to the project root, or None.
|
|
2148
|
+
It is governance, not an analyzable area: `validate` checks it directly, so `stale`
|
|
2149
|
+
never counts its files and `mark` never records it."""
|
|
2150
|
+
try:
|
|
2151
|
+
return ai_path(root).resolve().relative_to(Path(root).resolve()).as_posix()
|
|
2152
|
+
except ValueError:
|
|
2153
|
+
return None
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
def _in_docs_root(rel, docs_rel):
|
|
2157
|
+
rel = rel.replace("\\", "/").rstrip("/")
|
|
2158
|
+
return bool(docs_rel) and (rel == docs_rel or rel.startswith(docs_rel + "/"))
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
def cmd_mark(root, paths, note=None):
|
|
1678
2162
|
if not require_ai_docs(root, "mark"):
|
|
1679
2163
|
return 1
|
|
2164
|
+
if note is not None and (not note.strip() or "**" in note or "\n" in note):
|
|
2165
|
+
print("[ERROR] --note must be one line of text without '**'. Nothing was written.")
|
|
2166
|
+
return 1
|
|
1680
2167
|
f, lines, rows = parse_audit_plan(root)
|
|
1681
2168
|
use_git_ref = git_available(root) and not any(
|
|
1682
2169
|
git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
|
|
@@ -1703,6 +2190,10 @@ def cmd_mark(root, paths):
|
|
|
1703
2190
|
"refusing to mark (use a project-relative path, '.' for the root). "
|
|
1704
2191
|
"Nothing was written.")
|
|
1705
2192
|
return 1
|
|
2193
|
+
if _in_docs_root(key, docs_root_rel(root)):
|
|
2194
|
+
print(f"[note] {raw}: the docs root is not an area to mark -- `validate` checks it "
|
|
2195
|
+
"directly. Skipped.")
|
|
2196
|
+
continue
|
|
1706
2197
|
keys.append(key)
|
|
1707
2198
|
|
|
1708
2199
|
appended = []
|
|
@@ -1722,6 +2213,13 @@ def cmd_mark(root, paths):
|
|
|
1722
2213
|
|
|
1723
2214
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
1724
2215
|
f.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
2216
|
+
if note is not None:
|
|
2217
|
+
print(f"[ok] dated entry added to audit/{PROJECT_NOTES}: {add_dated_note(root, note)}")
|
|
2218
|
+
elif not _open_units(root):
|
|
2219
|
+
# no open unit's Diary covers this work: a release, a fix after closure
|
|
2220
|
+
print(f"[note] no open unit records this work. If it is a release or a change worth "
|
|
2221
|
+
f"finding later, re-run with --note \"<what was done>\": mark writes the "
|
|
2222
|
+
f"dated entry in audit/{PROJECT_NOTES} for you.")
|
|
1725
2223
|
if use_git_ref:
|
|
1726
2224
|
# F-056: the reference is born here, so this is where the gestures that
|
|
1727
2225
|
# orphan it are named. An amend was the observed one.
|
|
@@ -1801,15 +2299,56 @@ def _orient_user_settings_path():
|
|
|
1801
2299
|
|
|
1802
2300
|
|
|
1803
2301
|
def _orient_hook_commands(root):
|
|
1804
|
-
"""Yield (command, at_user_level) across every known client config:
|
|
1805
|
-
three project-relative files, then the machine-global user settings --
|
|
2302
|
+
"""Yield (command, at_user_level, place) across every known client config:
|
|
2303
|
+
the three project-relative files, then the machine-global user settings --
|
|
1806
2304
|
the file the installer wires (F-042), without which a fixed machine's
|
|
1807
|
-
projects would read as unwired forever.
|
|
2305
|
+
projects would read as unwired forever. `place` is a display label, never a
|
|
2306
|
+
home path (F-075 prints it)."""
|
|
1808
2307
|
for rel in ORIENT_HOOK_CONFIG_FILES:
|
|
1809
2308
|
for cmd in _orient_entries_in(Path(root) / rel):
|
|
1810
|
-
yield cmd, False
|
|
2309
|
+
yield cmd, False, rel
|
|
1811
2310
|
for cmd in _orient_entries_in(_orient_user_settings_path()):
|
|
1812
|
-
yield cmd, True
|
|
2311
|
+
yield cmd, True, "user settings"
|
|
2312
|
+
|
|
2313
|
+
|
|
2314
|
+
def _orient_entry_live(root, cmd, at_user_level):
|
|
2315
|
+
"""True unless the entry names a validator that does not resolve.
|
|
2316
|
+
|
|
2317
|
+
A matching entry whose tokens name no validator is "cannot tell" and counts
|
|
2318
|
+
as live (the lib.js contract: never report broken on cannot-tell). Relative
|
|
2319
|
+
tokens resolve against the project root (the vendored-validator
|
|
2320
|
+
convention); a USER-level relative token has no cwd to resolve against and
|
|
2321
|
+
is treated as cannot-tell."""
|
|
2322
|
+
token = None
|
|
2323
|
+
for m in _ORIENT_HOOK_TOKEN_RE.finditer(cmd):
|
|
2324
|
+
tok = m.group(1) if m.group(1) is not None else m.group(2)
|
|
2325
|
+
if any(tok.endswith(n) for n in ORIENT_HOOK_ENTRY_POINTS):
|
|
2326
|
+
token = tok
|
|
2327
|
+
break
|
|
2328
|
+
if token is None:
|
|
2329
|
+
return True
|
|
2330
|
+
tp = Path(token)
|
|
2331
|
+
if not tp.is_absolute():
|
|
2332
|
+
if at_user_level:
|
|
2333
|
+
return True
|
|
2334
|
+
tp = Path(root) / tp
|
|
2335
|
+
return tp.exists()
|
|
2336
|
+
|
|
2337
|
+
|
|
2338
|
+
def orient_wiring(root):
|
|
2339
|
+
"""{client: {command: place}} for the LIVE orient entries, per client.
|
|
2340
|
+
|
|
2341
|
+
Per client because two clients never read each other's settings: a Codex
|
|
2342
|
+
entry beside a Claude Code one is two clients oriented once each, not one
|
|
2343
|
+
session oriented twice. Identical command strings count once. A dead entry
|
|
2344
|
+
delivers no copy and is left out (F-075 FS3)."""
|
|
2345
|
+
out = {}
|
|
2346
|
+
for cmd, at_user_level, place in _orient_hook_commands(root):
|
|
2347
|
+
if not _orient_entry_live(root, cmd, at_user_level):
|
|
2348
|
+
continue
|
|
2349
|
+
client = "codex" if place.startswith(".codex") else "claude"
|
|
2350
|
+
out.setdefault(client, {}).setdefault(cmd.strip(), place)
|
|
2351
|
+
return out
|
|
1813
2352
|
|
|
1814
2353
|
|
|
1815
2354
|
def orient_hook_state(root):
|
|
@@ -1824,22 +2363,9 @@ def orient_hook_state(root):
|
|
|
1824
2363
|
the project root (the vendored-validator convention); a USER-level relative
|
|
1825
2364
|
token has no cwd to resolve against and is treated as cannot-tell."""
|
|
1826
2365
|
matched = False
|
|
1827
|
-
for cmd, at_user_level in _orient_hook_commands(root):
|
|
2366
|
+
for cmd, at_user_level, _place in _orient_hook_commands(root):
|
|
1828
2367
|
matched = True
|
|
1829
|
-
|
|
1830
|
-
for m in _ORIENT_HOOK_TOKEN_RE.finditer(cmd):
|
|
1831
|
-
tok = m.group(1) if m.group(1) is not None else m.group(2)
|
|
1832
|
-
if any(tok.endswith(n) for n in ORIENT_HOOK_ENTRY_POINTS):
|
|
1833
|
-
token = tok
|
|
1834
|
-
break
|
|
1835
|
-
if token is None:
|
|
1836
|
-
return "wired"
|
|
1837
|
-
tp = Path(token)
|
|
1838
|
-
if not tp.is_absolute():
|
|
1839
|
-
if at_user_level:
|
|
1840
|
-
return "wired" # cannot tell: no cwd exists at user level
|
|
1841
|
-
tp = Path(root) / tp
|
|
1842
|
-
if tp.exists():
|
|
2368
|
+
if _orient_entry_live(root, cmd, at_user_level):
|
|
1843
2369
|
return "wired"
|
|
1844
2370
|
return "dead" if matched else "unwired"
|
|
1845
2371
|
|
|
@@ -1869,15 +2395,30 @@ def print_orient_hook_note(root):
|
|
|
1869
2395
|
pass # detection must never break check
|
|
1870
2396
|
|
|
1871
2397
|
|
|
1872
|
-
def cmd_check(root, strict=False, hybrid=False):
|
|
2398
|
+
def cmd_check(root, strict=False, hybrid=False, extra_validate=None, extra_sections=()):
|
|
2399
|
+
"""An overlay adds its checks through the two hooks, never after the call:
|
|
2400
|
+
the summary line is the verdict, so every check that can fail runs before
|
|
2401
|
+
it. `extra_validate()` folds into the validate stage; each
|
|
2402
|
+
(title, fn) in `extra_sections` gets its own banner and summary rc."""
|
|
1873
2403
|
print("===== validate =====")
|
|
1874
2404
|
rc_v = cmd_validate(root, strict=strict, hybrid=hybrid)
|
|
2405
|
+
if extra_validate is not None:
|
|
2406
|
+
rc_v = max(rc_v, extra_validate())
|
|
2407
|
+
# A3 (F-073): check only -- it reads git refs, and validate stays file-local.
|
|
2408
|
+
# Advisory: printed, never part of any rc.
|
|
2409
|
+
for a in id_advisories(root):
|
|
2410
|
+
print(f"[note] {a}")
|
|
1875
2411
|
print("\n===== stale =====")
|
|
1876
2412
|
rc_s = cmd_stale(root, hybrid=hybrid)
|
|
1877
|
-
|
|
1878
|
-
|
|
2413
|
+
rcs = [("validate", rc_v), ("stale", rc_s)]
|
|
2414
|
+
for title, fn in extra_sections:
|
|
2415
|
+
print(f"\n===== {title} =====")
|
|
2416
|
+
rcs.append((title, fn()))
|
|
2417
|
+
failed = any(rc for _, rc in rcs)
|
|
2418
|
+
print(f"\ncheck: {'NOT CLEAN' if failed else 'CLEAN'} "
|
|
2419
|
+
f"({', '.join(f'{title} rc={rc}' for title, rc in rcs)})")
|
|
1879
2420
|
print_orient_hook_note(root)
|
|
1880
|
-
return 1 if
|
|
2421
|
+
return 1 if failed else 0
|
|
1881
2422
|
|
|
1882
2423
|
|
|
1883
2424
|
# --------------------------------------------------------------------- gate
|
|
@@ -1906,6 +2447,12 @@ REMIND_FALLBACK = (
|
|
|
1906
2447
|
)
|
|
1907
2448
|
|
|
1908
2449
|
|
|
2450
|
+
# F-075: constant, for every lens -- with sibling lenses installed nothing per
|
|
2451
|
+
# turn said to choose the lens. r19's bound rose to 600 for it (r32, owner).
|
|
2452
|
+
REMIND_LENS_SUFFIX = (" Above L1, with sibling lenses installed: choose the lens with "
|
|
2453
|
+
"routing.md and declare it.")
|
|
2454
|
+
|
|
2455
|
+
|
|
1909
2456
|
def cmd_remind(args):
|
|
1910
2457
|
"""UserPromptSubmit hook: print the lens's constant per-turn line. Always 0.
|
|
1911
2458
|
|
|
@@ -1914,21 +2461,31 @@ def cmd_remind(args):
|
|
|
1914
2461
|
try:
|
|
1915
2462
|
prof = profile()
|
|
1916
2463
|
line = prof.get("remind_line") or REMIND_FALLBACK.format(skill=prof["skill_name"])
|
|
1917
|
-
print(f"{prof['skill_name']}: {line}")
|
|
2464
|
+
print(f"{prof['skill_name']}: {line}{REMIND_LENS_SUFFIX}")
|
|
1918
2465
|
except Exception:
|
|
1919
2466
|
pass
|
|
1920
2467
|
return 0
|
|
1921
2468
|
|
|
2469
|
+
def read_hook_payload():
|
|
2470
|
+
"""The hook event JSON on stdin as a dict, or None when it cannot be read.
|
|
2471
|
+
|
|
2472
|
+
bytes -> utf-8-sig: the payload is UTF-8 JSON regardless of the console code
|
|
2473
|
+
page; '-sig' strips the BOM (PowerShell pipes)."""
|
|
2474
|
+
try:
|
|
2475
|
+
raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
|
|
2476
|
+
payload = json.loads(raw)
|
|
2477
|
+
except Exception:
|
|
2478
|
+
return None
|
|
2479
|
+
return payload if isinstance(payload, dict) else None
|
|
2480
|
+
|
|
2481
|
+
|
|
1922
2482
|
def cmd_gate(args):
|
|
1923
2483
|
file_path = args.file or ""
|
|
1924
2484
|
if args.hook:
|
|
2485
|
+
payload = read_hook_payload()
|
|
1925
2486
|
try:
|
|
1926
|
-
# bytes -> utf-8-sig: the hook payload is UTF-8 JSON regardless of the
|
|
1927
|
-
# console code page; '-sig' strips the BOM (PowerShell pipes)
|
|
1928
|
-
raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
|
|
1929
|
-
payload = json.loads(raw)
|
|
1930
2487
|
file_path = (payload.get("tool_input") or {}).get("file_path") or ""
|
|
1931
|
-
except
|
|
2488
|
+
except AttributeError:
|
|
1932
2489
|
return 0 # unparseable input: do not block
|
|
1933
2490
|
if not file_path:
|
|
1934
2491
|
return 0
|
|
@@ -1963,6 +2520,134 @@ def cmd_gate(args):
|
|
|
1963
2520
|
return 2
|
|
1964
2521
|
|
|
1965
2522
|
|
|
2523
|
+
# ------------------------------------------------------------------ verdict
|
|
2524
|
+
# F-076: Rule Zero's router verdict, checked at the edit. A field agent edited
|
|
2525
|
+
# without declaring one and said nothing stopped it. This reads the session
|
|
2526
|
+
# transcript the client hands every hook and refuses the edit (JSON deny, exit
|
|
2527
|
+
# 0) only on certainty; whatever it cannot tell resolves to silence. It never
|
|
2528
|
+
# prints `allow` -- that would skip the user's permission prompt. Owner: block
|
|
2529
|
+
# (REQUEST_guide_verdict_check.md R3), wired by default (R4, rulings.md r34).
|
|
2530
|
+
|
|
2531
|
+
VERDICT_EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
2532
|
+
VERDICT_MAX_BYTES = 256 * 1024 * 1024 # a larger transcript: cannot afford to tell
|
|
2533
|
+
VERDICT_LINE_MAX = 1024 * 1024 # assistant lines are small; skip the rest unparsed
|
|
2534
|
+
# Markdown emphasis between the word and the colon ("**router**: no match") and
|
|
2535
|
+
# "Router verdict:" are the same declaration: format drift resolves to silence.
|
|
2536
|
+
_VERDICT_ROUTER_RE = re.compile(r"router(?:[\s*_`]+verdict)?[\s*_`]*:(.*)", re.IGNORECASE)
|
|
2537
|
+
_VERDICT_NONE_RE = re.compile(r"[\s`*_\"'(]*(no match|absent)", re.IGNORECASE)
|
|
2538
|
+
_VERDICT_TRIVIAL_RE = re.compile(r"\blevel[\s*_`]*:[\s`*_]*[A-Z]1\b", re.IGNORECASE)
|
|
2539
|
+
_GUIDE_NAME_RE = re.compile(r"GUIDE_[A-Za-z0-9_-]+\.md")
|
|
2540
|
+
|
|
2541
|
+
|
|
2542
|
+
def transcript_facts(path):
|
|
2543
|
+
"""What the session transcript shows about the verdict duty, or None when it
|
|
2544
|
+
cannot be read within the bounds.
|
|
2545
|
+
|
|
2546
|
+
Returns {"assistant": entries parsed, "sidechain": a subagent's own transcript
|
|
2547
|
+
(Claude Code marks every entry of one `isSidechain`), "declared": a router verdict seen,
|
|
2548
|
+
"trivial": a `Level: X1` seen, "named": guides the verdicts name,
|
|
2549
|
+
"read": guide names that appeared in a non-edit tool's input}."""
|
|
2550
|
+
facts = {"assistant": 0, "sidechain": False, "declared": False, "trivial": False,
|
|
2551
|
+
"named": set(), "read": set()}
|
|
2552
|
+
try:
|
|
2553
|
+
p = Path(path)
|
|
2554
|
+
if not p.is_file() or p.stat().st_size > VERDICT_MAX_BYTES:
|
|
2555
|
+
return None
|
|
2556
|
+
with p.open("rb") as fh:
|
|
2557
|
+
for raw in fh:
|
|
2558
|
+
if len(raw) > VERDICT_LINE_MAX or b'"assistant"' not in raw:
|
|
2559
|
+
continue
|
|
2560
|
+
try:
|
|
2561
|
+
entry = json.loads(raw.decode("utf-8", errors="replace"))
|
|
2562
|
+
except ValueError:
|
|
2563
|
+
continue
|
|
2564
|
+
if not isinstance(entry, dict) or entry.get("type") != "assistant":
|
|
2565
|
+
continue
|
|
2566
|
+
content = (entry.get("message") or {}).get("content")
|
|
2567
|
+
if not isinstance(content, list):
|
|
2568
|
+
continue
|
|
2569
|
+
facts["assistant"] += 1
|
|
2570
|
+
if entry.get("isSidechain") is True:
|
|
2571
|
+
facts["sidechain"] = True
|
|
2572
|
+
for block in content:
|
|
2573
|
+
if not isinstance(block, dict):
|
|
2574
|
+
continue
|
|
2575
|
+
if block.get("type") == "text":
|
|
2576
|
+
for line in str(block.get("text") or "").splitlines():
|
|
2577
|
+
if _VERDICT_TRIVIAL_RE.search(line):
|
|
2578
|
+
facts["trivial"] = True
|
|
2579
|
+
m = _VERDICT_ROUTER_RE.search(line)
|
|
2580
|
+
if m:
|
|
2581
|
+
facts["declared"] = True
|
|
2582
|
+
if not _VERDICT_NONE_RE.match(m.group(1)):
|
|
2583
|
+
facts["named"].update(_GUIDE_NAME_RE.findall(m.group(1)))
|
|
2584
|
+
elif (block.get("type") == "tool_use"
|
|
2585
|
+
and block.get("name") not in VERDICT_EDIT_TOOLS):
|
|
2586
|
+
facts["read"].update(_GUIDE_NAME_RE.findall(
|
|
2587
|
+
json.dumps(block.get("input"), ensure_ascii=False)))
|
|
2588
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
2589
|
+
return None
|
|
2590
|
+
return facts
|
|
2591
|
+
|
|
2592
|
+
|
|
2593
|
+
def verdict_reason(payload, root):
|
|
2594
|
+
"""The refusal line for this edit event, or None when the edit may proceed."""
|
|
2595
|
+
if payload.get("tool_name") not in VERDICT_EDIT_TOOLS or payload.get("agent_id"):
|
|
2596
|
+
return None
|
|
2597
|
+
tool_input = payload.get("tool_input") or {}
|
|
2598
|
+
target = tool_input.get("file_path") or tool_input.get("notebook_path")
|
|
2599
|
+
transcript = payload.get("transcript_path")
|
|
2600
|
+
if not target or not transcript:
|
|
2601
|
+
return None
|
|
2602
|
+
try:
|
|
2603
|
+
Path(target).resolve().relative_to(root)
|
|
2604
|
+
except (ValueError, OSError):
|
|
2605
|
+
return None # outside the project: not this check's concern
|
|
2606
|
+
ref = ai_path(root, "reference")
|
|
2607
|
+
if not ref.is_dir():
|
|
2608
|
+
return None
|
|
2609
|
+
guides = {g.name for g in ref.glob("GUIDE_*.md") if g.is_file()}
|
|
2610
|
+
if not guides:
|
|
2611
|
+
return None
|
|
2612
|
+
facts = transcript_facts(transcript)
|
|
2613
|
+
if not facts or not facts["assistant"]:
|
|
2614
|
+
return None # unreadable, oversized or an unknown format: cannot tell
|
|
2615
|
+
if facts["sidechain"]:
|
|
2616
|
+
return None # a subagent works under its orchestrator's verdict
|
|
2617
|
+
if not (facts["declared"] or facts["trivial"]):
|
|
2618
|
+
return ("[sdlc verdict] No guide-router verdict in this session; this project has "
|
|
2619
|
+
f"guides ({docs_dir()}/reference/INDEX.md). Declare it with the level, e.g. "
|
|
2620
|
+
"\"Level: L2 · router: no match\" or \"router: GUIDE_x.md -> read\" "
|
|
2621
|
+
"(L1: \"Level: L1\"), then retry.")
|
|
2622
|
+
unread = sorted((facts["named"] & guides) - facts["read"])
|
|
2623
|
+
if unread:
|
|
2624
|
+
return (f"[sdlc verdict] The router verdict names {', '.join(unread)}, not read in "
|
|
2625
|
+
"this session. Read it, then retry.")
|
|
2626
|
+
return None
|
|
2627
|
+
|
|
2628
|
+
|
|
2629
|
+
def cmd_verdict(args):
|
|
2630
|
+
"""PreToolUse hook: print a JSON refusal or nothing. Always 0 -- exit 2 would
|
|
2631
|
+
block blind, and the wired command adds `; exit 0` for validators that cannot
|
|
2632
|
+
run this at all (missing, or older than it)."""
|
|
2633
|
+
if not args.hook:
|
|
2634
|
+
return 0
|
|
2635
|
+
try:
|
|
2636
|
+
payload = read_hook_payload()
|
|
2637
|
+
if payload is None:
|
|
2638
|
+
return 0
|
|
2639
|
+
root = Path(args.root).resolve() if args.root else find_project_root()
|
|
2640
|
+
reason = verdict_reason(payload, root)
|
|
2641
|
+
if reason:
|
|
2642
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
2643
|
+
"hookEventName": "PreToolUse",
|
|
2644
|
+
"permissionDecision": "deny",
|
|
2645
|
+
"permissionDecisionReason": reason}}, ensure_ascii=False))
|
|
2646
|
+
except Exception:
|
|
2647
|
+
pass
|
|
2648
|
+
return 0
|
|
2649
|
+
|
|
2650
|
+
|
|
1966
2651
|
# --------------------------------------------------------------------- plan
|
|
1967
2652
|
# Subagent Execution (Feature A). Zero-execution surface: this section and
|
|
1968
2653
|
# everything it calls MUST NOT spawn a process (no subprocess/os.system/eval/
|
|
@@ -2162,15 +2847,23 @@ REVIEW_POSITIONAL = {
|
|
|
2162
2847
|
}
|
|
2163
2848
|
|
|
2164
2849
|
|
|
2850
|
+
_UNESCAPED_PIPE_RE = re.compile(r"(?<!\\)\|")
|
|
2851
|
+
|
|
2852
|
+
|
|
2165
2853
|
def _table_cells(line):
|
|
2166
2854
|
"""Cells of a Markdown table row, or None when the line is not one.
|
|
2167
|
-
Separator rows (---, :---:, any alignment colons) are not data.
|
|
2855
|
+
Separator rows (---, :---:, any alignment colons) are not data. A cell may
|
|
2856
|
+
carry an escaped pipe (`\\|`, as `index` writes them): it does not split the
|
|
2857
|
+
row, and it reads back as `|` (F-075)."""
|
|
2168
2858
|
s = line.strip()
|
|
2169
2859
|
if not s.startswith("|"):
|
|
2170
2860
|
return None
|
|
2171
2861
|
if set(s) <= set("|-: "):
|
|
2172
2862
|
return None
|
|
2173
|
-
|
|
2863
|
+
body = s[1:]
|
|
2864
|
+
if body.endswith("|") and not body.endswith("\\|"):
|
|
2865
|
+
body = body[:-1]
|
|
2866
|
+
return [c.strip().replace("\\|", "|") for c in _UNESCAPED_PIPE_RE.split(body)]
|
|
2174
2867
|
|
|
2175
2868
|
|
|
2176
2869
|
def parse_review_log(text):
|
|
@@ -2235,6 +2928,13 @@ def _is_pass(verdict):
|
|
|
2235
2928
|
return re.search(r"\bPASS\b", (verdict or "").upper()) is not None
|
|
2236
2929
|
|
|
2237
2930
|
|
|
2931
|
+
def _is_not_due(verdict):
|
|
2932
|
+
"""A review ruled not due (review.md, a delta that only restates judged
|
|
2933
|
+
work): logged so a skip stays visible, but it is neither a review nor a
|
|
2934
|
+
finding, so it stays out of every count below (F-071)."""
|
|
2935
|
+
return (verdict or "").strip().upper().startswith("NOT DUE")
|
|
2936
|
+
|
|
2937
|
+
|
|
2238
2938
|
def _print_read_cost():
|
|
2239
2939
|
"""The cost side, printed with the benefit and NEVER without it: a catch
|
|
2240
2940
|
rate with no cost term justifies unbounded doctrine, and a cost term with
|
|
@@ -2275,6 +2975,8 @@ def cmd_benefit(root):
|
|
|
2275
2975
|
_print_read_cost()
|
|
2276
2976
|
return 0
|
|
2277
2977
|
rows, unparsed = parse_review_log(read_text(log))
|
|
2978
|
+
not_due = [r for r in rows if _is_not_due(r["verdict"])]
|
|
2979
|
+
rows = [r for r in rows if not _is_not_due(r["verdict"])]
|
|
2278
2980
|
# `tier` carries the MOMENT in Standalone (design / closure) and the
|
|
2279
2981
|
# reviewer WEIGHT in Hybrid (deep / light / code / guide / vision). A
|
|
2280
2982
|
# Hybrid row is a real review whose moment this column does not state, so
|
|
@@ -2299,9 +3001,11 @@ def cmd_benefit(root):
|
|
|
2299
3001
|
unstated_code += n
|
|
2300
3002
|
classified = moments["design"] + moments["closure"]
|
|
2301
3003
|
print("rows parsed: %d rows unparsed: %d%s"
|
|
2302
|
-
% (len(rows), len(unparsed),
|
|
3004
|
+
% (len(rows) + len(not_due), len(unparsed),
|
|
2303
3005
|
(" -> " + "; ".join("line %d: %s" % u for u in unparsed[:5]))
|
|
2304
3006
|
if unparsed else ""))
|
|
3007
|
+
print("reviews ruled not due (logged, counted in no figure below): %d"
|
|
3008
|
+
% len(not_due))
|
|
2305
3009
|
print("reviews with a stated moment: %d moment not stated by `tier`: %d%s"
|
|
2306
3010
|
% (len(classified), sum(unstated.values()),
|
|
2307
3011
|
(" (" + ", ".join("%s x%d" % (t, n) for t, n
|
|
@@ -2367,48 +3071,205 @@ def cmd_benefit(root):
|
|
|
2367
3071
|
return 0
|
|
2368
3072
|
|
|
2369
3073
|
|
|
3074
|
+
# --- orient helpers (F-075) ---------------------------------------------------
|
|
3075
|
+
SKILL_MD_CAP = 64 * 1024 # a SKILL.md is read for its name only
|
|
3076
|
+
|
|
3077
|
+
|
|
3078
|
+
def orient_fit(lines, budget, rel):
|
|
3079
|
+
"""The lines that fit `budget` characters (one newline each), cut at a line
|
|
3080
|
+
boundary and closed by a pointer to the file holding the rest. A single line
|
|
3081
|
+
wider than the room left shows its head. Never raises."""
|
|
3082
|
+
out, used = [], 0
|
|
3083
|
+
for i, line in enumerate(lines):
|
|
3084
|
+
if used + len(line) + 1 <= budget:
|
|
3085
|
+
out.append(line)
|
|
3086
|
+
used += len(line) + 1
|
|
3087
|
+
continue
|
|
3088
|
+
rest = len(lines) - i
|
|
3089
|
+
|
|
3090
|
+
def ptr(n):
|
|
3091
|
+
return f"(+{n} more lines -- read {rel})"
|
|
3092
|
+
room = budget - used - len(ptr(rest)) - 1
|
|
3093
|
+
if room > 40:
|
|
3094
|
+
head = line[:room - 4] + "..."
|
|
3095
|
+
out.append(head)
|
|
3096
|
+
used += len(head) + 1
|
|
3097
|
+
while out and used + len(ptr(rest)) + 1 > budget:
|
|
3098
|
+
used -= len(out.pop()) + 1
|
|
3099
|
+
rest += 1
|
|
3100
|
+
out.append(ptr(rest))
|
|
3101
|
+
return out
|
|
3102
|
+
return out
|
|
3103
|
+
|
|
3104
|
+
|
|
3105
|
+
def lens_table(skill_dir=None):
|
|
3106
|
+
"""{skill_name: lens} from the running skill's own `routing.md` par.0 table.
|
|
3107
|
+
|
|
3108
|
+
The Python twin of lib.js `lensTable()`, same parse: a row whose lens cell is
|
|
3109
|
+
`[a-z]+` and whose skill cell is a backticked `[a-z0-9-]+`. `routing.md` is a
|
|
3110
|
+
byte-identical shared file, so the family list cannot drift between
|
|
3111
|
+
distributions -- a literal list here is how kb and mkt once announced
|
|
3112
|
+
themselves as the code lens. Fail-open: {} on any failure."""
|
|
3113
|
+
try:
|
|
3114
|
+
base = Path(skill_dir) if skill_dir else Path(__file__).resolve().parent.parent
|
|
3115
|
+
table = {}
|
|
3116
|
+
for line in read_text(base / "routing.md").splitlines():
|
|
3117
|
+
cells = [c.strip() for c in line.split("|")]
|
|
3118
|
+
if len(cells) < 4:
|
|
3119
|
+
continue
|
|
3120
|
+
lens, skill = cells[1], cells[2]
|
|
3121
|
+
if re.fullmatch(r"[a-z]+", lens) and re.fullmatch(r"`[a-z0-9-]+`", skill):
|
|
3122
|
+
table.setdefault(skill[1:-1], lens)
|
|
3123
|
+
return table
|
|
3124
|
+
except Exception:
|
|
3125
|
+
return {}
|
|
3126
|
+
|
|
3127
|
+
|
|
3128
|
+
def _skills_dirs():
|
|
3129
|
+
"""The two places a sibling lens is installed: beside the running skill, and
|
|
3130
|
+
the user's Claude skills directory. AGENTIC_SDLC_SKILLS_DIRS (os.pathsep
|
|
3131
|
+
list; empty = none) is the test seam, like AGENTIC_SDLC_USER_SETTINGS."""
|
|
3132
|
+
seam = os.environ.get("AGENTIC_SDLC_SKILLS_DIRS")
|
|
3133
|
+
if seam is not None:
|
|
3134
|
+
return [Path(s) for s in seam.split(os.pathsep) if s]
|
|
3135
|
+
cfg = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
3136
|
+
return [Path(__file__).resolve().parents[2],
|
|
3137
|
+
(Path(cfg) if cfg else Path.home() / ".claude") / "skills"]
|
|
3138
|
+
|
|
3139
|
+
|
|
3140
|
+
def installed_sibling_lenses():
|
|
3141
|
+
"""Names of the family's other lenses installed on this machine (sorted).
|
|
3142
|
+
|
|
3143
|
+
Only a name that appears in `lens_table()` AND equals a `SKILL.md` `name:`
|
|
3144
|
+
exactly can come out -- no text of a sibling's files reaches the output
|
|
3145
|
+
(P-TM T3). Fail-open: [] on any failure."""
|
|
3146
|
+
try:
|
|
3147
|
+
own = profile()["skill_name"]
|
|
3148
|
+
family = lens_table()
|
|
3149
|
+
found = set()
|
|
3150
|
+
for base in _skills_dirs():
|
|
3151
|
+
if not base.is_dir():
|
|
3152
|
+
continue
|
|
3153
|
+
for sub in base.iterdir():
|
|
3154
|
+
f = sub / "SKILL.md"
|
|
3155
|
+
try:
|
|
3156
|
+
if not f.is_file() or f.stat().st_size > SKILL_MD_CAP:
|
|
3157
|
+
continue
|
|
3158
|
+
m = re.search(r"^name:\s*(\S+)", read_text(f), re.M)
|
|
3159
|
+
except OSError:
|
|
3160
|
+
continue
|
|
3161
|
+
if m and m.group(1) in family and m.group(1) != own:
|
|
3162
|
+
found.add(m.group(1))
|
|
3163
|
+
return sorted(found)
|
|
3164
|
+
except Exception:
|
|
3165
|
+
return []
|
|
3166
|
+
|
|
3167
|
+
|
|
3168
|
+
def _orient_router_lines(text):
|
|
3169
|
+
"""One line per guide from the generated router; a hand-written one as written."""
|
|
3170
|
+
if not re.search(r"GENERATED by \S+ index - do not edit by hand", text):
|
|
3171
|
+
return text.splitlines()
|
|
3172
|
+
rows = []
|
|
3173
|
+
for line in text.splitlines():
|
|
3174
|
+
cells = _table_cells(line)
|
|
3175
|
+
if not cells or len(cells) < 3 or cells[0] == "Guide":
|
|
3176
|
+
continue
|
|
3177
|
+
rows.append(f"- {cells[0]} ({cells[1]}) -- {cells[2]}")
|
|
3178
|
+
return rows or ['no guides yet -- the router verdict is "no match"']
|
|
3179
|
+
|
|
3180
|
+
|
|
3181
|
+
def _orient_registry_lines(text):
|
|
3182
|
+
"""One line per workstream plus the project-wide notes from the generated
|
|
3183
|
+
registry; a hand-written handoff as written."""
|
|
3184
|
+
if not is_generated_registry(text):
|
|
3185
|
+
return text.splitlines()
|
|
3186
|
+
table, _, notes = text.partition("\n## Project-wide notes")
|
|
3187
|
+
lines = []
|
|
3188
|
+
for line in table.splitlines():
|
|
3189
|
+
c = _table_cells(line)
|
|
3190
|
+
if not c or len(c) < 6 or c[0].lower() == "workstream":
|
|
3191
|
+
continue
|
|
3192
|
+
lines.append(f"- {c[0]} \u00b7 {c[1]} \u00b7 {c[2]} \u00b7 {c[3]} -- {c[5]}")
|
|
3193
|
+
if not lines:
|
|
3194
|
+
lines.append("no open workstreams")
|
|
3195
|
+
notes = notes.strip()
|
|
3196
|
+
if notes:
|
|
3197
|
+
lines += ["Project-wide notes:"] + notes.splitlines()
|
|
3198
|
+
return lines
|
|
3199
|
+
|
|
3200
|
+
|
|
2370
3201
|
def cmd_orient(args):
|
|
2371
|
-
"""SessionStart hook: emit a bounded, repo-sourced
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
(P-TM T2). No subprocess/eval anywhere in this call graph.
|
|
3202
|
+
"""SessionStart hook: emit a bounded, repo-sourced orientation to stdout and
|
|
3203
|
+
ALWAYS return 0 (fail-open, P-TM T8) -- a session hook must never block the
|
|
3204
|
+
session or surface a traceback. Zero-execution (P-TM T1): reads a fixed
|
|
3205
|
+
hard-coded doc set, confine_under each (P-TM T3), bounds the WHOLE output
|
|
3206
|
+
(P-TM T2). No subprocess/eval anywhere in this call graph.
|
|
3207
|
+
|
|
3208
|
+
F-075: what decides the next action comes first -- triage, the lens choice,
|
|
3209
|
+
the guide router, the workstream registry -- then README; INDEX is a pointer.
|
|
3210
|
+
Docs are READ in orient_docs() order (docs_site and the T3 test pin it) and
|
|
3211
|
+
EMITTED in priority order."""
|
|
2376
3212
|
try:
|
|
2377
3213
|
root = Path(args.root).resolve() if getattr(args, "root", None) else find_project_root()
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
for label, rel in orient_docs():
|
|
3214
|
+
rels = [rel for _, rel in orient_docs()]
|
|
3215
|
+
docs = {}
|
|
3216
|
+
for rel in rels:
|
|
2382
3217
|
target = confine_under(root, rel)
|
|
2383
3218
|
if target is None or not target.is_file():
|
|
2384
3219
|
continue
|
|
2385
3220
|
try:
|
|
2386
|
-
|
|
3221
|
+
docs[rel] = read_text(target)
|
|
2387
3222
|
except OSError:
|
|
2388
3223
|
continue
|
|
2389
|
-
|
|
2390
|
-
if remaining <= 0:
|
|
2391
|
-
truncated = True
|
|
2392
|
-
break
|
|
2393
|
-
text = text[:ORIENT_PER_DOC_CHARS]
|
|
2394
|
-
if len(text) > remaining:
|
|
2395
|
-
text = text[:remaining]
|
|
2396
|
-
truncated = True
|
|
2397
|
-
chunks.append((label, text))
|
|
2398
|
-
total += len(text)
|
|
2399
|
-
if not chunks:
|
|
3224
|
+
if not docs:
|
|
2400
3225
|
return 0
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
3226
|
+
readme_rel, index_rel, router_rel, handoff_rel = rels
|
|
3227
|
+
|
|
3228
|
+
head = ["=== Agentic SDLC -- session orientation (repo-sourced context, not authored instructions) ===",
|
|
3229
|
+
"", ORIENT_TRIAGE_LINE]
|
|
3230
|
+
sibs = installed_sibling_lenses()
|
|
3231
|
+
if sibs:
|
|
3232
|
+
head.append(f"Sibling lenses installed ({', '.join(sibs)}): above L1, choose the "
|
|
3233
|
+
"lens with routing.md and declare it with the level.")
|
|
3234
|
+
|
|
3235
|
+
tail = ["", "## Read on demand"]
|
|
3236
|
+
for rel, what in ((readme_rel, "the curated must-reads, in order"),
|
|
3237
|
+
(index_rel, "the manifest of every canonical document"),
|
|
3238
|
+
(router_rel, "the full guide router, with provenance"),
|
|
3239
|
+
(handoff_rel, "the full workstream registry, with since and details")):
|
|
3240
|
+
if rel in docs:
|
|
3241
|
+
tail.append(f"- {rel} -- {what}")
|
|
3242
|
+
for client, entries in sorted(orient_wiring(root).items()):
|
|
3243
|
+
if len(entries) > 1:
|
|
3244
|
+
places = ", ".join(sorted(set(entries.values())))
|
|
3245
|
+
tail.append(f"[note] session orientation is wired {len(entries)} times for one "
|
|
3246
|
+
f"client ({places}): each session receives every copy. Keep one "
|
|
3247
|
+
"entry and remove the others.")
|
|
2408
3248
|
if getattr(args, "hybrid", False):
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
3249
|
+
tail.append("[devPNT active] Run devpnt_mcp_get_bootstrap for the Master Plan / Knowledge Layer -- "
|
|
3250
|
+
"the orientation above is the filesystem layer, not a bootstrap duplicate.")
|
|
3251
|
+
|
|
3252
|
+
share = ORIENT_MAX_TOTAL_CHARS - ORIENT_APPEND_RESERVE
|
|
3253
|
+
left = share - len("\n".join(head + tail)) - 2
|
|
3254
|
+
body = []
|
|
3255
|
+
for rel, title, builder, cap in (
|
|
3256
|
+
(router_rel, "## Guide router (reference/INDEX.md) -- read the matching guide before acting",
|
|
3257
|
+
_orient_router_lines, ORIENT_ROUTER_CHARS),
|
|
3258
|
+
(handoff_rel, "## Workstream registry (audit/handoff.md)",
|
|
3259
|
+
_orient_registry_lines, ORIENT_REGISTRY_CHARS),
|
|
3260
|
+
(readme_rel, "## Reading guide (README)", str.splitlines, None)):
|
|
3261
|
+
if rel not in docs:
|
|
3262
|
+
continue
|
|
3263
|
+
room = left - len(title) - 2
|
|
3264
|
+
if cap is not None:
|
|
3265
|
+
room = min(room, cap)
|
|
3266
|
+
if room <= 0:
|
|
3267
|
+
continue
|
|
3268
|
+
lines = orient_fit(builder(docs[rel].strip()), room, rel)
|
|
3269
|
+
section = ["", title] + lines
|
|
3270
|
+
body += section
|
|
3271
|
+
left -= len("\n".join(section)) + 1
|
|
3272
|
+
print("\n".join(head + body + tail))
|
|
2412
3273
|
return 0
|
|
2413
3274
|
except Exception:
|
|
2414
3275
|
return 0
|
|
@@ -2625,11 +3486,18 @@ def main(argv=None):
|
|
|
2625
3486
|
|
|
2626
3487
|
mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
|
|
2627
3488
|
mp.add_argument("paths", nargs="+", help="paths relative to the project root")
|
|
3489
|
+
mp.add_argument("--note", help="also write a dated entry in audit/project_notes.md "
|
|
3490
|
+
"(a release, work outside an open unit)")
|
|
2628
3491
|
gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
|
|
2629
3492
|
gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
|
|
2630
3493
|
gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
|
|
2631
3494
|
gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")
|
|
2632
3495
|
|
|
3496
|
+
vp = sub.add_parser("verdict", parents=[common],
|
|
3497
|
+
help="PreToolUse hook: refuse an edit with no router verdict "
|
|
3498
|
+
"or an unread named guide (fail-open, always exit 0)")
|
|
3499
|
+
vp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
|
|
3500
|
+
|
|
2633
3501
|
sub.add_parser("benefit", parents=[common],
|
|
2634
3502
|
help="report what the review gates caught (and what the "
|
|
2635
3503
|
"doctrine costs to read) -- a report, never a gate: "
|
|
@@ -2671,6 +3539,10 @@ def main(argv=None):
|
|
|
2671
3539
|
# and says so rather than exiting non-zero.
|
|
2672
3540
|
print(f"[note] {exc}")
|
|
2673
3541
|
discovered, name = None, DEFAULT_DOCS_DIR
|
|
3542
|
+
elif args.cmd == "verdict":
|
|
3543
|
+
# F-076: a PreToolUse check that cannot tell which root governs refuses
|
|
3544
|
+
# nothing and says nothing -- its stdout is read as the hook's decision.
|
|
3545
|
+
return 0
|
|
2674
3546
|
else:
|
|
2675
3547
|
print(f"[ERROR] {exc}")
|
|
2676
3548
|
return 1
|
|
@@ -2678,6 +3550,8 @@ def main(argv=None):
|
|
|
2678
3550
|
|
|
2679
3551
|
if args.cmd == "gate":
|
|
2680
3552
|
return cmd_gate(args)
|
|
3553
|
+
if args.cmd == "verdict":
|
|
3554
|
+
return cmd_verdict(args)
|
|
2681
3555
|
if args.cmd == "orient":
|
|
2682
3556
|
return cmd_orient(args)
|
|
2683
3557
|
if args.cmd == "remind":
|
|
@@ -2693,7 +3567,7 @@ def main(argv=None):
|
|
|
2693
3567
|
if args.cmd == "stale":
|
|
2694
3568
|
return cmd_stale(root, hybrid=args.hybrid)
|
|
2695
3569
|
if args.cmd == "mark":
|
|
2696
|
-
return cmd_mark(root, args.paths)
|
|
3570
|
+
return cmd_mark(root, args.paths, args.note)
|
|
2697
3571
|
if args.cmd == "benefit":
|
|
2698
3572
|
return cmd_benefit(root)
|
|
2699
3573
|
if args.cmd == "migrate":
|