@antoneeo/kb-agentic-skill 1.0.0

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.
@@ -0,0 +1,846 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """KB Agentic — the KNOWLEDGE domain entry point, and its overlay.
4
+
5
+ The family's shared spine lives in `sdlc_core.py`, byte-identical in every
6
+ distribution; this file is the knowledge OVERLAY on top of it. Since F-024/F-025
7
+ it is deliberately not thin: the claim ledger (assertions with provenance, held
8
+ conflicts) and the topic graph (placement, edges, integrity) are genuinely this
9
+ domain's own and stay here. What converges is the spine, not the knowledge.
10
+
11
+ From the overlay:
12
+ claim-id <path> <locator> [--qty "..."] compute a claim id
13
+ claim-id --fill <file> fill missing ids in a claim table
14
+ graph [--root R] topic-graph integrity checks
15
+ corpus [--root R] corpus checks (supersession, digests)
16
+ index / validate / check spine behaviour PLUS the kb surface
17
+ (byte-identical output on a tree
18
+ with no topics/ or corpus/)
19
+
20
+ Every other spine subcommand (stale, mark, gate, orient, plan, migrate, and any
21
+ future one) is FORWARDED to the spine untouched: dispatch is "not intercepted ->
22
+ forward", never a hand-copied command tuple, so a new spine command cannot be
23
+ silently dropped here.
24
+
25
+ Both files must sit in the same directory. Copying this one alone fails at
26
+ import, loudly, which is the intended failure. The core alone stays runnable,
27
+ but for kb it no longer behaves identically: it runs none of the claim or graph
28
+ checks (ENFORCEMENT.md says so).
29
+
30
+ Pure stdlib. ASCII output only (Windows-console safe).
31
+ """
32
+ import argparse
33
+ import hashlib
34
+ import re
35
+ import sys
36
+ from pathlib import Path
37
+
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
39
+
40
+ try:
41
+ import sdlc_core
42
+ except ImportError as exc: # pragma: no cover - exercised by TS12, not by unit tests
43
+ sys.stderr.write(
44
+ "[ERROR] sdlc_check.py cannot find sdlc_core.py next to it: " + str(exc) + "\n"
45
+ " The validator ships as TWO files since the multi-domain core.\n"
46
+ " Copy both, or run sdlc_core.py directly.\n")
47
+ sys.exit(1)
48
+
49
+ # Re-export the core's surface: existing importers (`import sdlc_check as sc`)
50
+ # and the test batteries reach for these names on this module. The shared
51
+ # batteries bind `sdlc_core` directly, so nothing defined below can shadow what
52
+ # they test; overlay names carry a kb_ prefix for readability, not as a guard.
53
+ from sdlc_core import * # noqa: F401,F403
54
+ from sdlc_core import _map_refs # noqa: F401 underscore helper used by the batteries
55
+
56
+ DOMAIN = "knowledge"
57
+
58
+ sdlc_core.set_entry_point(DOMAIN, provides=("code", "knowledge"))
59
+
60
+ sdlc_core.set_profile(
61
+ skill_name="kb-agentic",
62
+ unit_noun="topic",
63
+ support_files=("templates.md", "taxonomy.md", "guides.md", "vision.md",
64
+ "distillation.md", "reconciliation.md", "elicitation.md",
65
+ "review.md", "dispatch.md", "routing.md", "ENFORCEMENT.md"),
66
+ capabilities=(
67
+ # spine
68
+ "triage", "write_triggers", "workstream_registry", "vision_gate",
69
+ "design_review_gate", "guide_router", "worktree_hygiene",
70
+ # knowledge overlay
71
+ "taxonomy_pass", "subagent_dispatch", "question_discipline",
72
+ ),
73
+ design_gate_between=("### 3. Request Analysis & Taxonomy Pass",
74
+ "### 4. Knowledge Processing & Distillation"),
75
+ )
76
+
77
+ # --------------------------------------------------------------- claim ledger
78
+ # F-025. The machine detects and holds; it never decides. Every function below
79
+ # is pure and stdlib — the battery calls them directly.
80
+
81
+ CLAIM_COLUMNS = ("id", "claim", "valid", "qty", "about", "source", "prov", "state")
82
+ CLAIM_HEADING = "## Claims"
83
+ PROVENANCES = ("GIVEN", "ELICITED", "DERIVED", "RULING")
84
+ SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
85
+ OWNS_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}/[a-z0-9][a-z0-9-]{0,63}$")
86
+ DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
87
+ LOC_PAGE_RE = re.compile(r"^p=(\d+)@(\d+)-(\d+)$")
88
+ LOC_LINE_RE = re.compile(r"^L(\d+)-(\d+)$")
89
+ LOC_CELL_RE = re.compile(r"^Sheet[^!]+![A-Z]+\d+$")
90
+
91
+ # Unit conventions, documented in templates.md. effort in person-days
92
+ # (8h day, 5d week, 21d month); duration in calendar days; cost within ONE
93
+ # currency (no exchange rates offline); count unit-matched.
94
+ QTY_UNITS = {
95
+ "effort": {"h": 0.125, "d": 1.0, "w": 5.0, "mo": 21.0, "fte-mo": 21.0},
96
+ "duration": {"h": 1.0 / 24, "d": 1.0, "w": 7.0, "mo": 30.0},
97
+ }
98
+
99
+
100
+ def kb_qty_norm(text):
101
+ """'12000 EUR cost' -> ('cost', 12000.0, 'EUR') or None for '-'.
102
+
103
+ Raises ValueError on anything else: a malformed quantity must be a finding,
104
+ never a silently-ignored cell.
105
+ """
106
+ text = (text or "").strip()
107
+ if text in ("", "-"):
108
+ return None
109
+ parts = text.split()
110
+ if len(parts) != 3:
111
+ raise ValueError("qty must be '<value> <unit> <kind>' or '-': %r" % text)
112
+ value, unit, kind = parts
113
+ try:
114
+ value = float(value)
115
+ except ValueError:
116
+ raise ValueError("qty value is not a number: %r" % text)
117
+ if kind not in ("effort", "cost", "duration", "count"):
118
+ raise ValueError("qty kind must be effort/cost/duration/count: %r" % text)
119
+ if kind in QTY_UNITS and unit not in QTY_UNITS[kind]:
120
+ raise ValueError("unknown %s unit %r (known: %s)"
121
+ % (kind, unit, "/".join(sorted(QTY_UNITS[kind]))))
122
+ return kind, value, unit
123
+
124
+
125
+ def kb_qty_key(text):
126
+ """The id component: 'cost:12000:EUR', or '' when the row carries no qty."""
127
+ q = kb_qty_norm(text)
128
+ if q is None:
129
+ return ""
130
+ kind, value, unit = q
131
+ return "%s:%s:%s" % (kind, ("%g" % value), unit)
132
+
133
+
134
+ def kb_qty_sum(rows_qty):
135
+ """Sum ('kind', value, unit) triples of ONE kind. Returns (total, unit).
136
+
137
+ effort/duration normalise to days; cost sums within one currency and
138
+ REFUSES a mixed-currency set; count requires one unit. Mixed kinds refuse.
139
+ """
140
+ triples = [kb_qty_norm(q) for q in rows_qty]
141
+ triples = [t for t in triples if t]
142
+ if not triples:
143
+ return None
144
+ kinds = {t[0] for t in triples}
145
+ if len(kinds) > 1:
146
+ raise ValueError("mixed qty kinds cannot sum: %s" % "/".join(sorted(kinds)))
147
+ kind = triples[0][0]
148
+ if kind in QTY_UNITS:
149
+ total = sum(v * QTY_UNITS[kind][u] for _, v, u in triples)
150
+ return total, "d"
151
+ units = {t[2] for t in triples}
152
+ if len(units) > 1:
153
+ raise ValueError("mixed %s units cannot sum offline: %s"
154
+ % (kind, "/".join(sorted(units))))
155
+ return sum(v for _, v, _ in triples), triples[0][2]
156
+
157
+
158
+ def kb_claim_id(source_path, locator, qty_key=""):
159
+ """First 12 hex of sha256(path#locator#qty). Text excluded on purpose:
160
+ an LLM re-extraction paraphrases; the location and the figure do not move."""
161
+ payload = "%s#%s#%s" % (source_path, locator, qty_key)
162
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
163
+
164
+
165
+ def kb_parse_scope(text):
166
+ """'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
167
+ ('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
168
+ text = (text or "").strip()
169
+ if text in ("", "-"):
170
+ return ("always",)
171
+ if text.startswith("if "):
172
+ return ("if", text[3:].strip())
173
+ m = re.match(r"^(?:from (\S+))?\s*(?:until (\S+))?$", text)
174
+ if not m or (m.group(1) is None and m.group(2) is None):
175
+ raise ValueError("valid must be '-', 'from X', 'until X', "
176
+ "'from X until Y' or 'if <cond>': %r" % text)
177
+ lo, hi = m.group(1), m.group(2)
178
+ for d in (lo, hi):
179
+ if d is not None and not DATE_RE.match(d):
180
+ raise ValueError("scope dates are YYYY-MM-DD: %r" % text)
181
+ return ("window", lo, hi)
182
+
183
+
184
+ def kb_scopes_overlap(a, b):
185
+ """Half-open windows: `from` inclusive, `until` exclusive — so
186
+ 'until 2026-03-01' and 'from 2026-03-01' are DISJOINT. 'if' conditions are
187
+ free text, undecidable, therefore treated as overlapping everything."""
188
+ sa, sb = kb_parse_scope(a), kb_parse_scope(b)
189
+ if sa[0] == "if" or sb[0] == "if" or sa[0] == "always" or sb[0] == "always":
190
+ return True
191
+ _, alo, ahi = sa
192
+ _, blo, bhi = sb
193
+ lo = max(alo or "0000-00-00", blo or "0000-00-00")
194
+ hi = min(ahi or "9999-99-99", bhi or "9999-99-99")
195
+ return lo < hi
196
+
197
+
198
+ def kb_parse_claims(text):
199
+ """Rows of the `## Claims` table -> (rows, errors).
200
+
201
+ Exact arity in BOTH directions: a short row and a long row both error —
202
+ never padded, never truncated. (The mkt find_table pattern this copies
203
+ tolerates a ragged tail; here a stray `|` in a hostile cell must be loud.)
204
+ Each row: dict with CLAIM_COLUMNS keys + '_line' (1-based, for findings).
205
+ """
206
+ rows, errors = [], []
207
+ lines = text.split("\n")
208
+ try:
209
+ start = next(i for i, ln in enumerate(lines)
210
+ if ln.strip() == CLAIM_HEADING)
211
+ except StopIteration:
212
+ return [], []
213
+ header_seen = False
214
+ for i in range(start + 1, len(lines)):
215
+ ln = lines[i].strip()
216
+ if ln.startswith("## "):
217
+ break
218
+ if not ln.startswith("|"):
219
+ continue
220
+ cells = [c.strip() for c in ln.strip("|").split("|")]
221
+ if not header_seen:
222
+ header_seen = True # header row
223
+ if [c.lower() for c in cells] != list(CLAIM_COLUMNS):
224
+ errors.append((i + 1, "claim table header must be exactly: "
225
+ + " | ".join(CLAIM_COLUMNS)))
226
+ return [], errors
227
+ continue
228
+ if set(ln) <= {"|", "-", " ", ":"}:
229
+ continue # separator row
230
+ if len(cells) != len(CLAIM_COLUMNS):
231
+ errors.append((i + 1, "claim row has %d cells, expected %d "
232
+ "(a '|' inside a cell must be escaped)"
233
+ % (len(cells), len(CLAIM_COLUMNS))))
234
+ continue
235
+ row = dict(zip(CLAIM_COLUMNS, cells))
236
+ row["_line"] = i + 1
237
+ rows.append(row)
238
+ return rows, errors
239
+
240
+
241
+ def kb_fill_ids(text):
242
+ """Fill empty id cells from source#locator#qty. The byte diff is confined
243
+ to id cells: everything else comes back verbatim (TL-T6)."""
244
+ lines = text.split("\n")
245
+ rows, _ = kb_parse_claims(text)
246
+ for row in rows:
247
+ if row["id"]:
248
+ continue
249
+ src = row["source"].split(";")[0].strip()
250
+ if "#" not in src:
251
+ continue
252
+ path, loc = src.rsplit("#", 1)
253
+ try:
254
+ qk = kb_qty_key(row["qty"])
255
+ except ValueError:
256
+ continue
257
+ new_id = kb_claim_id(path, loc, qk)
258
+ ln = lines[row["_line"] - 1]
259
+ head, rest = ln.split("|", 2)[0], ln.split("|", 2)[2]
260
+ first_cell = ln.split("|", 2)[1]
261
+ lines[row["_line"] - 1] = "%s|%s|%s" % (
262
+ head, first_cell.replace(first_cell.strip() or "\x00", new_id)
263
+ if first_cell.strip() else " " + new_id + " ", rest)
264
+ return "\n".join(lines)
265
+
266
+
267
+ def _note_frontmatter(root, rel):
268
+ p = root / rel
269
+ if not p.is_file():
270
+ return None
271
+ return sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
272
+
273
+
274
+ def kb_check_claims(root):
275
+ """All mechanical claim checks over topics/*.md. Returns (errors, warnings,
276
+ notes) as message lists. Findings only — never per-node status lines."""
277
+ errors, warnings, notes = [], [], []
278
+ topics = root / "topics"
279
+ if not topics.is_dir():
280
+ return errors, warnings, notes
281
+ all_ids = {} # id -> "file:line"
282
+ all_rows = {} # id -> (row, rel)
283
+ per_file_rows = []
284
+ for p in sorted(topics.glob("*.md")):
285
+ rel = "topics/" + p.name
286
+ text = sdlc_core.read_text(p)
287
+ rows, perrs = kb_parse_claims(text)
288
+ for line, msg in perrs:
289
+ errors.append("%s:%d: %s" % (rel, line, msg))
290
+ per_file_rows.append((rel, rows))
291
+ for row in rows:
292
+ where = "%s:%d" % (rel, row["_line"])
293
+ # --- source: resolve, confine, span-check ---
294
+ sources = [s.strip() for s in row["source"].split(";") if s.strip()]
295
+ if not sources:
296
+ errors.append("%s: claim has no source" % where)
297
+ continue
298
+ first = sources[0]
299
+ for src in sources:
300
+ if "#" not in src:
301
+ errors.append("%s: source %r has no locator" % (where, src))
302
+ continue
303
+ path_s, loc = src.rsplit("#", 1)
304
+ target = sdlc_core.confine_under(root, path_s)
305
+ if target is None:
306
+ errors.append("%s: source %r escapes the docs root" % (where, src))
307
+ continue
308
+ if not target.is_file():
309
+ errors.append("%s: source %r does not resolve — a claim whose "
310
+ "origin cannot be reopened is model knowledge"
311
+ % (where, src))
312
+ continue
313
+ kb_check_locator(target, loc, where, errors)
314
+ # --- provenance ---
315
+ prov = row["prov"]
316
+ if prov not in PROVENANCES:
317
+ errors.append("%s: prov %r not in %s" % (where, prov, "/".join(PROVENANCES)))
318
+ elif prov in ("DERIVED", "RULING", "ELICITED"):
319
+ meta = _note_frontmatter(root, first.rsplit("#", 1)[0])
320
+ if meta is None:
321
+ pass # unresolvable source already reported
322
+ elif prov == "DERIVED" and not meta.get("derived_from"):
323
+ errors.append("%s: DERIVED claim's note carries no 'derived_from:' "
324
+ "— model knowledge disguised as a source" % where)
325
+ elif prov == "RULING" and not meta.get("basis"):
326
+ errors.append("%s: RULING note carries no 'basis:' — a preference "
327
+ "is not a fact; no basis, no ruling" % where)
328
+ # --- grammar cells ---
329
+ try:
330
+ kb_parse_scope(row["valid"])
331
+ except ValueError as e:
332
+ errors.append("%s: %s" % (where, e))
333
+ try:
334
+ qk = kb_qty_key(row["qty"])
335
+ except ValueError as e:
336
+ errors.append("%s: %s" % (where, e))
337
+ qk = None
338
+ if row["about"] not in ("", "-"):
339
+ m = re.match(r"^([a-z0-9-]+) -> ([a-z0-9-]+)$", row["about"])
340
+ if not m:
341
+ errors.append("%s: about must be '<predicate> -> <slug>' or '-'"
342
+ % where)
343
+ # --- id: recompute, or note fill-pending ---
344
+ if not row["id"]:
345
+ notes.append("%s: id missing — fill-pending, run "
346
+ "'sdlc_check.py claim-id --fill %s'" % (where, rel))
347
+ elif qk is not None and "#" in first:
348
+ path_s, loc = first.rsplit("#", 1)
349
+ expect = kb_claim_id(path_s, loc, qk)
350
+ if row["id"] != expect:
351
+ errors.append("%s: id %s does not recompute from its first "
352
+ "source (+qty) — expected %s; the text may be "
353
+ "corrected freely, the provenance may not be "
354
+ "moved silently" % (where, row["id"], expect))
355
+ if row["id"]:
356
+ if row["id"] in all_ids:
357
+ errors.append("%s: duplicate id %s (also at %s) — uniqueness "
358
+ "is global across topics/" % (where, row["id"],
359
+ all_ids[row["id"]]))
360
+ else:
361
+ all_ids[row["id"]] = where
362
+ all_rows[row["id"]] = (row, rel)
363
+ # --- state machine integrity, global ---
364
+ for _, rows in per_file_rows:
365
+ for row in rows:
366
+ if not row["id"]:
367
+ continue
368
+ where = all_ids.get(row["id"], row["id"])
369
+ state = row["state"]
370
+ if state == "OK":
371
+ continue
372
+ m = re.match(r"^(CONTESTED|SUPERSEDED) ([0-9a-f, ]+)$", state)
373
+ if not m:
374
+ errors.append("%s: state must be OK, 'CONTESTED <id>[,..]' or "
375
+ "'SUPERSEDED <id>': %r" % (where, state))
376
+ continue
377
+ kind = m.group(1)
378
+ targets = [t.strip() for t in m.group(2).split(",") if t.strip()]
379
+ for t in targets:
380
+ other = all_rows.get(t)
381
+ if other is None:
382
+ errors.append("%s: %s points at id %s which resolves to no row "
383
+ "— deleting one side of a disagreement breaks "
384
+ "the check, it does not clean up" % (where, kind, t))
385
+ continue
386
+ orow, _ = other
387
+ if kind == "CONTESTED":
388
+ if orow["state"].startswith("SUPERSEDED"):
389
+ errors.append("%s: CONTESTED points at SUPERSEDED row %s — "
390
+ "the set must be rewritten when a member is "
391
+ "superseded" % (where, t))
392
+ elif not (orow["state"].startswith("CONTESTED")
393
+ and row["id"] in orow["state"]):
394
+ errors.append("%s: CONTESTED is not symmetric — %s does not "
395
+ "name %s back; one flipped cell must never "
396
+ "silently end a disagreement"
397
+ % (where, t, row["id"]))
398
+ return errors, warnings, notes
399
+
400
+
401
+ def kb_check_locator(target, loc, where, errors):
402
+ """A locator must address an existing span of the stored bytes."""
403
+ m = LOC_PAGE_RE.match(loc)
404
+ if m:
405
+ page, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
406
+ if a >= b:
407
+ errors.append("%s: locator %r has an empty span" % (where, loc))
408
+ return
409
+ # offsets address the stored extraction (<stem>.txt beside the original)
410
+ ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
411
+ if not ext.is_file():
412
+ errors.append("%s: no stored extraction %s for locator %r — offsets "
413
+ "must address kept bytes" % (where, ext.name, loc))
414
+ return
415
+ pages = sdlc_core.read_text(ext).split("\f")
416
+ if page < 1 or page > len(pages):
417
+ errors.append("%s: locator %r addresses page %d of %d" %
418
+ (where, loc, page, len(pages)))
419
+ elif b > len(pages[page - 1]):
420
+ errors.append("%s: locator %r spans past the end of page %d "
421
+ "(%d chars)" % (where, loc, page, len(pages[page - 1])))
422
+ return
423
+ m = LOC_LINE_RE.match(loc)
424
+ if m:
425
+ a, b = int(m.group(1)), int(m.group(2))
426
+ if a > b or a < 1:
427
+ errors.append("%s: locator %r has an invalid line range" % (where, loc))
428
+ return
429
+ n = sdlc_core.read_text(target).count("\n") + 1
430
+ if b > n:
431
+ errors.append("%s: locator %r spans past line %d" % (where, loc, n))
432
+ return
433
+ if not LOC_CELL_RE.match(loc):
434
+ errors.append("%s: locator %r matches no known form "
435
+ "(p=<n>@<a>-<b> / L<a>-<b> / Sheet<s>!<cell>)" % (where, loc))
436
+
437
+
438
+ # ---------------------------------------------------------------- topic graph
439
+ # F-024. Findings only; the graph is held in memory, rebuilt per run.
440
+
441
+ def kb_load_topics(root):
442
+ """topics/*.md -> {slug: meta+body info}. The files ARE the state."""
443
+ nodes = {}
444
+ topics = root / "topics"
445
+ if not topics.is_dir():
446
+ return nodes
447
+ for p in sorted(topics.glob("*.md")):
448
+ if p.name == "INDEX.md":
449
+ continue
450
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
451
+ slug = (meta.get("topic") or p.stem).strip()
452
+ nodes[slug] = {"meta": meta, "file": "topics/" + p.name}
453
+ return nodes
454
+
455
+
456
+ def _as_list(v):
457
+ if v is None:
458
+ return []
459
+ if isinstance(v, str):
460
+ v = v.strip()
461
+ if v.startswith("[") and v.endswith("]"):
462
+ return [x.strip() for x in v[1:-1].split(",") if x.strip()]
463
+ return [v] if v else []
464
+ return list(v)
465
+
466
+
467
+ def kb_graph_check(root):
468
+ """Integrity of the topic graph. Errors/warnings only — never a per-node
469
+ status line (the work-management Non-Goal forbids the collected surface)."""
470
+ errors, warnings = [], []
471
+ nodes = kb_load_topics(root)
472
+ if not nodes:
473
+ return errors, warnings
474
+ import difflib
475
+ live = {s: n for s, n in nodes.items()
476
+ if (n["meta"].get("status") or "").strip() != "SUPERSEDED"}
477
+
478
+ def resolve(slug):
479
+ """Follow tombstone redirects to a live slug, or None."""
480
+ seen = set()
481
+ while slug in nodes and slug not in seen:
482
+ seen.add(slug)
483
+ n = nodes[slug]
484
+ if (n["meta"].get("status") or "").strip() == "SUPERSEDED":
485
+ slug = (n["meta"].get("redirect_to") or "").strip()
486
+ continue
487
+ return slug
488
+ return None
489
+
490
+ owns_seen = {}
491
+ for slug, n in nodes.items():
492
+ rel = n["file"]
493
+ if not SLUG_RE.match(slug):
494
+ errors.append("%s: slug %r fails the grammar ^[a-z0-9][a-z0-9-]{0,63}$"
495
+ % (rel, slug))
496
+ if (n["meta"].get("status") or "").strip() == "SUPERSEDED":
497
+ tgt = (n["meta"].get("redirect_to") or "").strip()
498
+ if not tgt or resolve(tgt) is None:
499
+ errors.append("%s: tombstone redirect_to %r resolves to no live node"
500
+ % (rel, tgt))
501
+ continue
502
+ for parent in _as_list(n["meta"].get("parents")):
503
+ if not SLUG_RE.match(parent):
504
+ errors.append("%s: parent %r fails the slug grammar" % (rel, parent))
505
+ elif resolve(parent) is None:
506
+ errors.append("%s: parent %r resolves to no live node" % (rel, parent))
507
+ for c in _as_list(n["meta"].get("owns")):
508
+ if not OWNS_RE.match(c):
509
+ errors.append("%s: owns entry %r fails the grammar "
510
+ "<slug>/<concept>" % (rel, c))
511
+ elif c in owns_seen:
512
+ errors.append("%s: concept %r owned twice (also by %s) — one owner "
513
+ "per concept" % (rel, c, owns_seen[c]))
514
+ else:
515
+ owns_seen[c] = rel
516
+ rl = (n["meta"].get("related") or "").strip()
517
+ if rl and resolve(rl) is None:
518
+ errors.append("%s: related %r resolves to no live node" % (rel, rl))
519
+ # cycles + reachability on primary parents, live nodes only
520
+ roots = [s for s, n in live.items() if not _as_list(n["meta"].get("parents"))]
521
+ for slug, n in live.items():
522
+ seen = set()
523
+ cur = slug
524
+ while cur is not None:
525
+ if cur in seen:
526
+ errors.append("%s: cycle through %r — a detached ring is invisible "
527
+ "to descent forever" % (n["file"], cur))
528
+ break
529
+ seen.add(cur)
530
+ parents = _as_list(live.get(cur, {}).get("meta", {}).get("parents")) \
531
+ if cur in live else []
532
+ cur = resolve(parents[0]) if parents else None
533
+ if roots:
534
+ reachable = set()
535
+ frontier = list(roots)
536
+ children = {}
537
+ for s, n in live.items():
538
+ for parent in _as_list(n["meta"].get("parents")):
539
+ rp = resolve(parent)
540
+ if rp:
541
+ children.setdefault(rp, []).append(s)
542
+ while frontier:
543
+ cur = frontier.pop()
544
+ if cur in reachable:
545
+ continue
546
+ reachable.add(cur)
547
+ frontier.extend(children.get(cur, []))
548
+ for slug, n in live.items():
549
+ if slug not in reachable and slug != "unplaced":
550
+ errors.append("%s: unreachable from any root — descent is the only "
551
+ "retrieval path, so this node is lost, not odd"
552
+ % n["file"])
553
+ # near-duplicate warning: catches listino/listini, not listino/pricing
554
+ slugs = sorted(live)
555
+ for i, a in enumerate(slugs):
556
+ for b in slugs[i + 1:]:
557
+ pa = _as_list(live[a]["meta"].get("parents"))
558
+ pb = _as_list(live[b]["meta"].get("parents"))
559
+ if pa and pa == pb and difflib.SequenceMatcher(None, a, b).ratio() > 0.85:
560
+ warnings.append("%s and %s: same parents and >0.85 name similarity — "
561
+ "possible duplicate; the semantic case belongs to "
562
+ "the router evals" % (a, b))
563
+ return errors, warnings
564
+
565
+
566
+ def kb_build_topic_index(root):
567
+ """slug | description | parents | synonyms — a router, not a status board."""
568
+ nodes = kb_load_topics(root)
569
+ lines = ["# Topic Index", "",
570
+ "<!-- GENERATED by sdlc_check.py index - do not edit by hand -->", "",
571
+ "| topic | description | parents | synonyms |",
572
+ "|---|---|---|---|"]
573
+ for slug in sorted(nodes):
574
+ n = nodes[slug]
575
+ if (n["meta"].get("status") or "").strip() == "SUPERSEDED":
576
+ continue
577
+ lines.append("| %s | %s | %s | %s |" % (
578
+ slug, (n["meta"].get("description") or "").strip(),
579
+ ", ".join(_as_list(n["meta"].get("parents"))),
580
+ ", ".join(_as_list(n["meta"].get("synonyms")))))
581
+ return "\n".join(lines) + "\n"
582
+
583
+
584
+ def kb_build_corpus_index(root):
585
+ """One row per corpus artifact, from sidecars and note frontmatter."""
586
+ corpus = root / "corpus"
587
+ lines = ["# Corpus Index", "",
588
+ "<!-- GENERATED by sdlc_check.py index - do not edit by hand -->", ""]
589
+ given = corpus / "given"
590
+ if given.is_dir():
591
+ lines.append("## given/")
592
+ for meta_p in sorted(given.glob("*.meta.md")):
593
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
594
+ orig = meta_p.name[:-len(".meta.md")]
595
+ sup = (meta.get("supersedes") or "").strip()
596
+ lines.append("- `%s` — %s%s" % (
597
+ orig, (meta.get("date") or "undated"),
598
+ (" — supersedes `%s`" % sup) if sup else ""))
599
+ notes = corpus / "notes"
600
+ if notes.is_dir():
601
+ lines.append("")
602
+ lines.append("## notes/")
603
+ for p in sorted(notes.glob("*.md")):
604
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
605
+ origin = (meta.get("origin") or
606
+ ("derived" if meta.get("derived_from") else
607
+ ("ruling" if meta.get("basis") else "unknown")))
608
+ lines.append("- `%s` — %s" % (p.name, origin))
609
+ return "\n".join(lines) + "\n"
610
+
611
+
612
+ def kb_corpus_check(root):
613
+ """Corpus integrity: digests, supersession, laundered notes. Findings only."""
614
+ errors, warnings = [], []
615
+ corpus = root / "corpus"
616
+ if not corpus.is_dir():
617
+ return errors, warnings
618
+ superseded = set()
619
+ given = corpus / "given"
620
+ if given.is_dir():
621
+ for meta_p in sorted(given.glob("*.meta.md")):
622
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
623
+ orig = given / meta_p.name[:-len(".meta.md")]
624
+ rel = "corpus/given/" + orig.name
625
+ if not orig.is_file():
626
+ errors.append("%s: sidecar exists but the original is gone" % rel)
627
+ continue
628
+ recorded = (meta.get("sha256") or "").strip()
629
+ if recorded:
630
+ actual = kb_sha256_bytes(orig)
631
+ if actual != recorded:
632
+ errors.append("%s: raw-byte digest changed since ingest — "
633
+ "given/ is never edited; this is the check, "
634
+ "not a convention" % rel)
635
+ sup = (meta.get("supersedes") or "").strip()
636
+ if sup:
637
+ superseded.add(sup)
638
+ if not (given / sup).is_file():
639
+ warnings.append("%s: supersedes %r which is not in given/"
640
+ % (rel, sup))
641
+ notes = corpus / "notes"
642
+ if notes.is_dir():
643
+ for p in sorted(notes.glob("*.md")):
644
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
645
+ if not (meta.get("derived_from") or meta.get("origin")
646
+ or meta.get("basis")):
647
+ errors.append("corpus/notes/%s: neither 'derived_from:' nor "
648
+ "'origin:' nor 'basis:' — model knowledge disguised "
649
+ "as a source" % p.name)
650
+ # claims resting on superseded originals (UC4)
651
+ if superseded:
652
+ topics = root / "topics"
653
+ if topics.is_dir():
654
+ for p in sorted(topics.glob("*.md")):
655
+ rows, _ = kb_parse_claims(sdlc_core.read_text(p))
656
+ for row in rows:
657
+ for src in row["source"].split(";"):
658
+ name = Path(src.split("#")[0].strip()).name
659
+ if name in superseded:
660
+ warnings.append(
661
+ "topics/%s:%d: claim rests on %s, which a newer "
662
+ "version supersedes — re-verify or re-place"
663
+ % (p.name, row["_line"], name))
664
+ return errors, warnings
665
+
666
+
667
+ def kb_sha256_bytes(path):
668
+ """RAW-byte digest for binaries. The spine's sha256_file normalizes CRLF->LF
669
+ (right for text snapshots, wrong for binaries, where a hostile 0D0A/0A pair
670
+ would collide)."""
671
+ h = hashlib.sha256()
672
+ with open(path, "rb") as f:
673
+ for chunk in iter(lambda: f.read(65536), b""):
674
+ h.update(chunk)
675
+ return h.hexdigest()
676
+
677
+
678
+ # ------------------------------------------------------------------ commands
679
+
680
+ INTERCEPTED = {"index", "validate", "check", "graph", "corpus", "claim-id"}
681
+
682
+
683
+ def _kb_root(args):
684
+ """Resolve the PROJECT root exactly as the spine's main does, so --docs-dir,
685
+ the env seam and the two-roots-refuse behaviour hold on intercepted commands.
686
+ Same call, same order: resolve_docs_dir(args, root) -> (discovered, name).
687
+ Spine cmd_* take the project root; kb helpers take the docs root under it."""
688
+ discovered, name = sdlc_core.resolve_docs_dir(args, getattr(args, "root", None))
689
+ sdlc_core.set_docs_dir(name)
690
+ root = (Path(args.root).resolve() if args.root
691
+ else (discovered or sdlc_core.find_project_root()))
692
+ return root, root / name
693
+
694
+
695
+ def kb_cmd_graph(docs):
696
+ errors, warnings = kb_graph_check(docs)
697
+ ce, cw, cn = kb_check_claims(docs)
698
+ for w in warnings + cw:
699
+ print("[warn] %s" % w)
700
+ for n in cn:
701
+ print("[note] %s" % n)
702
+ for e in errors + ce:
703
+ print("[ERROR] %s" % e)
704
+ total_e = len(errors) + len(ce)
705
+ if total_e:
706
+ print("Graph: %d errors, %d warnings." % (total_e, len(warnings) + len(cw)))
707
+ return 1
708
+ if not (docs / "topics").is_dir():
709
+ print("[ok] no topics/ - nothing to check")
710
+ else:
711
+ print("[ok] topic graph and claim ledger consistent "
712
+ "(%d warnings)" % (len(warnings) + len(cw)))
713
+ return 0
714
+
715
+
716
+ def kb_cmd_corpus(docs):
717
+ errors, warnings = kb_corpus_check(docs)
718
+ for w in warnings:
719
+ print("[warn] %s" % w)
720
+ for e in errors:
721
+ print("[ERROR] %s" % e)
722
+ if errors:
723
+ print("Corpus: %d errors, %d warnings." % (len(errors), len(warnings)))
724
+ return 1
725
+ if not (docs / "corpus").is_dir():
726
+ print("[ok] no corpus/ - nothing to check")
727
+ else:
728
+ print("[ok] corpus consistent (%d warnings)" % len(warnings))
729
+ return 0
730
+
731
+
732
+ def kb_cmd_index(root, docs):
733
+ rc = sdlc_core.cmd_index(root)
734
+ if (docs / "topics").is_dir():
735
+ out = docs / "topics" / "INDEX.md"
736
+ out.write_text(kb_build_topic_index(docs), encoding="utf-8")
737
+ print("[ok] topic index regenerated: %s" % out)
738
+ if (docs / "corpus").is_dir():
739
+ out = docs / "corpus" / "INDEX.md"
740
+ out.write_text(kb_build_corpus_index(docs), encoding="utf-8")
741
+ print("[ok] corpus index regenerated: %s" % out)
742
+ return rc
743
+
744
+
745
+ def _kb_extra_validate(docs):
746
+ """The kb additions to validate: generated-index freshness for the overlay's
747
+ two indexes. Prints nothing on a tree without them (TS-K7)."""
748
+ rc = 0
749
+ checks = (("topics", kb_build_topic_index), ("corpus", kb_build_corpus_index))
750
+ for dirname, builder in checks:
751
+ idx = docs / dirname / "INDEX.md"
752
+ if (docs / dirname).is_dir() and idx.is_file():
753
+ if sdlc_core.norm_text(sdlc_core.read_text(idx)) != \
754
+ sdlc_core.norm_text(builder(docs)):
755
+ print("[ERROR] %s/INDEX.md not aligned: run 'sdlc_check.py index'"
756
+ % dirname)
757
+ rc = 1
758
+ return rc
759
+
760
+
761
+ def kb_cmd_validate(root, docs, strict=False, hybrid=False):
762
+ rc = sdlc_core.cmd_validate(root, strict=strict, hybrid=hybrid)
763
+ return max(rc, _kb_extra_validate(docs))
764
+
765
+
766
+ def kb_cmd_check(root, docs, strict=False, hybrid=False):
767
+ # The spine's check owns its banners and summary line: reuse it whole, so a
768
+ # tree with no kb surface gets byte-identical output. The kb checks run
769
+ # after, and only when the surface exists.
770
+ rc = sdlc_core.cmd_check(root, strict=strict, hybrid=hybrid)
771
+ rc = max(rc, _kb_extra_validate(docs))
772
+ if (docs / "topics").is_dir() or (docs / "corpus").is_dir():
773
+ print("===== graph =====")
774
+ rc = max(rc, kb_cmd_graph(docs))
775
+ print("===== corpus =====")
776
+ rc = max(rc, kb_cmd_corpus(docs))
777
+ return rc
778
+
779
+
780
+ def kb_cmd_claim_id(args):
781
+ if args.fill:
782
+ p = Path(args.path)
783
+ if not p.is_file():
784
+ print("[ERROR] no such file: %s" % p)
785
+ return 2
786
+ before = sdlc_core.read_text(p)
787
+ after = kb_fill_ids(before)
788
+ if after != before:
789
+ p.write_text(after, encoding="utf-8")
790
+ print("[ok] ids filled in %s" % p)
791
+ else:
792
+ print("[ok] nothing to fill in %s" % p)
793
+ return 0
794
+ if not args.locator:
795
+ print("[ERROR] claim-id needs <path> <locator>, or --fill <file>")
796
+ return 2
797
+ print(kb_claim_id(args.path, args.locator, kb_qty_key(args.qty or "-")))
798
+ return 0
799
+
800
+
801
+ def main(argv=None):
802
+ argv = list(sys.argv[1:] if argv is None else argv)
803
+ # Forward-by-default: anything not intercepted goes to the spine untouched.
804
+ # Never a hand-copied command tuple - that is how a spine command gets
805
+ # silently dropped (mkt_check.py ships that exact defect with `migrate`).
806
+ if not argv or argv[0] not in INTERCEPTED:
807
+ return sdlc_core.main(argv)
808
+ ap = argparse.ArgumentParser(prog="sdlc_check.py (kb overlay)")
809
+ sub = ap.add_subparsers(dest="cmd", required=True)
810
+ for name in ("index", "graph", "corpus"):
811
+ p = sub.add_parser(name)
812
+ p.add_argument("--root")
813
+ p.add_argument("--docs-dir")
814
+ for name in ("validate", "check"):
815
+ p = sub.add_parser(name)
816
+ p.add_argument("--root")
817
+ p.add_argument("--docs-dir")
818
+ p.add_argument("--strict", action="store_true")
819
+ p.add_argument("--hybrid", action="store_true")
820
+ p = sub.add_parser("claim-id")
821
+ p.add_argument("path")
822
+ p.add_argument("locator", nargs="?")
823
+ p.add_argument("--qty")
824
+ p.add_argument("--fill", action="store_true")
825
+ args = ap.parse_args(argv)
826
+ if args.cmd == "claim-id":
827
+ return kb_cmd_claim_id(args)
828
+ try:
829
+ root, docs = _kb_root(args)
830
+ except sdlc_core.AmbiguousDocsRoot as e:
831
+ # same behaviour as the spine for non-exempt commands
832
+ print("[ERROR] %s" % e)
833
+ return 1
834
+ if args.cmd == "index":
835
+ return kb_cmd_index(root, docs)
836
+ if args.cmd == "graph":
837
+ return kb_cmd_graph(docs)
838
+ if args.cmd == "corpus":
839
+ return kb_cmd_corpus(docs)
840
+ if args.cmd == "validate":
841
+ return kb_cmd_validate(root, docs, strict=args.strict, hybrid=args.hybrid)
842
+ return kb_cmd_check(root, docs, strict=args.strict, hybrid=args.hybrid)
843
+
844
+
845
+ if __name__ == "__main__":
846
+ sys.exit(main())