@antoneeo/agentic-sdlc-skill 1.34.0 → 1.35.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,2089 @@
1
+ #!/usr/bin/env python3
2
+ """Shared project memory and knowledge integrity. No entry-point profile side effects.
3
+
4
+ Extracted from the KB overlay; the kb_* API and stored formats remain compatible.
5
+ Every distribution ships this module alongside its entry point and sdlc_core.py.
6
+ """
7
+ import argparse
8
+ import os
9
+ import hashlib
10
+ import ntpath
11
+ import posixpath
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+ import sdlc_core
16
+ from sdlc_core import * # compatibility with the existing KB implementation
17
+ from sdlc_core import _map_refs
18
+
19
+ # --------------------------------------------------------------- claim ledger
20
+ # F-025. The machine detects and holds; it never decides. Every function below
21
+ # is pure and stdlib — the battery calls them directly.
22
+
23
+ CLAIM_COLUMNS = ("id", "claim", "valid", "qty", "about", "source", "prov", "state")
24
+ CLAIM_HEADING = "## Claims"
25
+ PROVENANCES = ("GIVEN", "ELICITED", "DERIVED", "RULING", "IMPORTED")
26
+ SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
27
+ OWNS_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}/[a-z0-9][a-z0-9-]{0,63}$")
28
+ DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
29
+ LOC_PAGE_RE = re.compile(r"^p=(\d+)@(\d+)-(\d+)$")
30
+ LOC_LINE_RE = re.compile(r"^L(\d+)-(\d+)$")
31
+ LOC_CELL_RE = re.compile(r"^Sheet[^!]+![A-Z]+\d+$")
32
+ # F-031. How far a source has been read, in the unit its locators address.
33
+ EXTRACTED_THROUGH_RE = re.compile(r"^(?:complete|p=(\d+)|L(\d+))$")
34
+
35
+ # Unit conventions, documented in templates.md. effort in person-days
36
+ # (8h day, 5d week, 21d month); duration in calendar days; cost within ONE
37
+ # currency (no exchange rates offline); count unit-matched.
38
+ QTY_UNITS = {
39
+ "effort": {"h": 0.125, "d": 1.0, "w": 5.0, "mo": 21.0, "fte-mo": 21.0},
40
+ "duration": {"h": 1.0 / 24, "d": 1.0, "w": 7.0, "mo": 30.0},
41
+ }
42
+
43
+
44
+ def kb_qty_norm(text):
45
+ """'12000 EUR cost' -> ('cost', 12000.0, 'EUR') or None for '-'.
46
+
47
+ Raises ValueError on anything else: a malformed quantity must be a finding,
48
+ never a silently-ignored cell.
49
+ """
50
+ text = (text or "").strip()
51
+ if text in ("", "-"):
52
+ return None
53
+ parts = text.split()
54
+ if len(parts) != 3:
55
+ raise ValueError("qty must be '<value> <unit> <kind>' or '-': %r" % text)
56
+ value, unit, kind = parts
57
+ try:
58
+ value = float(value)
59
+ except ValueError:
60
+ raise ValueError("qty value is not a number: %r" % text)
61
+ if kind not in ("effort", "cost", "duration", "count"):
62
+ raise ValueError("qty kind must be effort/cost/duration/count: %r" % text)
63
+ if kind in QTY_UNITS and unit not in QTY_UNITS[kind]:
64
+ raise ValueError("unknown %s unit %r (known: %s)"
65
+ % (kind, unit, "/".join(sorted(QTY_UNITS[kind]))))
66
+ return kind, value, unit
67
+
68
+
69
+ def kb_qty_key(text):
70
+ """The id component: 'cost:12000:EUR', or '' when the row carries no qty."""
71
+ q = kb_qty_norm(text)
72
+ if q is None:
73
+ return ""
74
+ kind, value, unit = q
75
+ return "%s:%s:%s" % (kind, ("%g" % value), unit)
76
+
77
+
78
+ def kb_qty_sum(rows_qty):
79
+ """Sum ('kind', value, unit) triples of ONE kind. Returns (total, unit).
80
+
81
+ effort/duration normalise to days; cost sums within one currency and
82
+ REFUSES a mixed-currency set; count requires one unit. Mixed kinds refuse.
83
+ """
84
+ triples = [kb_qty_norm(q) for q in rows_qty]
85
+ triples = [t for t in triples if t]
86
+ if not triples:
87
+ return None
88
+ kinds = {t[0] for t in triples}
89
+ if len(kinds) > 1:
90
+ raise ValueError("mixed qty kinds cannot sum: %s" % "/".join(sorted(kinds)))
91
+ kind = triples[0][0]
92
+ if kind in QTY_UNITS:
93
+ total = sum(v * QTY_UNITS[kind][u] for _, v, u in triples)
94
+ return total, "d"
95
+ units = {t[2] for t in triples}
96
+ if len(units) > 1:
97
+ raise ValueError("mixed %s units cannot sum offline: %s"
98
+ % (kind, "/".join(sorted(units))))
99
+ return sum(v for _, v, _ in triples), triples[0][2]
100
+
101
+
102
+ def kb_claim_id(source_path, locator, qty_key=""):
103
+ """First 12 hex of sha256(path#locator#qty). Text excluded on purpose:
104
+ an LLM re-extraction paraphrases; the location and the figure do not move."""
105
+ payload = "%s#%s#%s" % (source_path, locator, qty_key)
106
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
107
+
108
+
109
+ # F-047: bundling tells for the atomicity note in kb_check_claims. Literal
110
+ # substrings, deliberately few and conservative: the note is a hint, and a
111
+ # noisy hint trains readers to skip notes. Field-calibrated on the motivating
112
+ # reviewer-comment shape ("states X, that Y, and that Z").
113
+ KB_BUNDLE_HINTS = (", and that ", "; ", ", while ", " and also ")
114
+
115
+
116
+ def kb_unquote(value):
117
+ """Strip ONE pair of wrapping YAML quotes (then re-strip whitespace).
118
+ load_frontmatter keeps values verbatim by design; paths with spaces force
119
+ quoting, so value-consuming readers unquote at the point of use. Field
120
+ defect 2026-08-28: 102 false dangling original_path warns on one real
121
+ corpus, and a quoted supersedes: would silently MISS the supersession."""
122
+ v = (value or "").strip()
123
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
124
+ return v[1:-1].strip()
125
+ return v
126
+
127
+
128
+ def kb_parse_scope(text):
129
+ """'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
130
+ ('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
131
+ text = (text or "").strip()
132
+ if text in ("", "-"):
133
+ return ("always",)
134
+ if text.startswith("if "):
135
+ return ("if", text[3:].strip())
136
+ m = re.match(r"^(?:from (\S+))?\s*(?:until (\S+))?$", text)
137
+ if not m or (m.group(1) is None and m.group(2) is None):
138
+ raise ValueError("valid must be '-', 'from X', 'until X', "
139
+ "'from X until Y' or 'if <cond>': %r" % text)
140
+ lo, hi = m.group(1), m.group(2)
141
+ for d in (lo, hi):
142
+ if d is not None and not DATE_RE.match(d):
143
+ raise ValueError("scope dates are YYYY-MM-DD: %r" % text)
144
+ return ("window", lo, hi)
145
+
146
+
147
+ def kb_scopes_overlap(a, b):
148
+ """Half-open windows: `from` inclusive, `until` exclusive — so
149
+ 'until 2026-03-01' and 'from 2026-03-01' are DISJOINT. 'if' conditions are
150
+ free text, undecidable, therefore treated as overlapping everything."""
151
+ sa, sb = kb_parse_scope(a), kb_parse_scope(b)
152
+ if sa[0] == "if" or sb[0] == "if" or sa[0] == "always" or sb[0] == "always":
153
+ return True
154
+ _, alo, ahi = sa
155
+ _, blo, bhi = sb
156
+ lo = max(alo or "0000-00-00", blo or "0000-00-00")
157
+ hi = min(ahi or "9999-99-99", bhi or "9999-99-99")
158
+ return lo < hi
159
+
160
+
161
+ def kb_parse_claims(text):
162
+ """Rows of the `## Claims` table -> (rows, errors).
163
+
164
+ Exact arity in BOTH directions: a short row and a long row both error —
165
+ never padded, never truncated. (The mkt find_table pattern this copies
166
+ tolerates a ragged tail; here a stray `|` in a hostile cell must be loud.)
167
+ Each row: dict with CLAIM_COLUMNS keys + '_line' (1-based, for findings).
168
+ """
169
+ rows, errors = [], []
170
+ lines = text.split("\n")
171
+ try:
172
+ start = next(i for i, ln in enumerate(lines)
173
+ if ln.strip() == CLAIM_HEADING)
174
+ except StopIteration:
175
+ return [], []
176
+ header_seen = False
177
+ for i in range(start + 1, len(lines)):
178
+ ln = lines[i].strip()
179
+ if ln.startswith("## "):
180
+ break
181
+ if not ln.startswith("|"):
182
+ continue
183
+ cells = [c.strip() for c in ln.strip("|").split("|")]
184
+ if not header_seen:
185
+ header_seen = True # header row
186
+ if [c.lower() for c in cells] != list(CLAIM_COLUMNS):
187
+ errors.append((i + 1, "claim table header must be exactly: "
188
+ + " | ".join(CLAIM_COLUMNS)))
189
+ return [], errors
190
+ continue
191
+ if set(ln) <= {"|", "-", " ", ":"}:
192
+ continue # separator row
193
+ if len(cells) != len(CLAIM_COLUMNS):
194
+ errors.append((i + 1, "claim row has %d cells, expected %d "
195
+ "(a '|' inside a cell must be escaped)"
196
+ % (len(cells), len(CLAIM_COLUMNS))))
197
+ continue
198
+ row = dict(zip(CLAIM_COLUMNS, cells))
199
+ row["_line"] = i + 1
200
+ rows.append(row)
201
+ return rows, errors
202
+
203
+
204
+ def kb_fill_ids(text):
205
+ """Fill empty id cells from source#locator#qty. The byte diff is confined
206
+ to id cells: everything else comes back verbatim (TL-T6)."""
207
+ lines = text.split("\n")
208
+ rows, _ = kb_parse_claims(text)
209
+ for row in rows:
210
+ if row["id"]:
211
+ continue
212
+ src = row["source"].split(";")[0].strip()
213
+ if "#" not in src:
214
+ continue
215
+ path, loc = src.rsplit("#", 1)
216
+ try:
217
+ qk = kb_qty_key(row["qty"])
218
+ except ValueError:
219
+ continue
220
+ new_id = kb_claim_id(path, loc, qk)
221
+ ln = lines[row["_line"] - 1]
222
+ head, rest = ln.split("|", 2)[0], ln.split("|", 2)[2]
223
+ first_cell = ln.split("|", 2)[1]
224
+ lines[row["_line"] - 1] = "%s|%s|%s" % (
225
+ head, first_cell.replace(first_cell.strip() or "\x00", new_id)
226
+ if first_cell.strip() else " " + new_id + " ", rest)
227
+ return "\n".join(lines)
228
+
229
+
230
+ def _note_frontmatter(root, rel):
231
+ """The frontmatter that DECLARES the cited file, and the path it came from.
232
+
233
+ A corpus artifact carries none of its own: `given/x.txt` is bytes, and what
234
+ says how those bytes were obtained is the `x.txt.meta.md` sidecar beside it.
235
+ Resolving the cited path alone returned `{}` for such a file -- which the
236
+ caller cannot tell apart from "resolved, field absent" -- and that made
237
+ DERIVED, RULING and IMPORTED impossible for every claim citing `given/`.
238
+
239
+ Sidecar FIRST when one exists, the cited file otherwise. Not "non-.md ->
240
+ sidecar": a verbatim `.md` source stored in `given/` is declared by its
241
+ sidecar exactly like a `.txt` extraction, and reading its own frontmatter
242
+ would read the SOURCE's, which says nothing about how it was extracted. A
243
+ `corpus/notes/*.md` note has no sidecar and still resolves to its own.
244
+
245
+ Returns `(meta, label)`. The label names the file actually read, so a
246
+ finding can say where it looked instead of sending the reader to the wrong
247
+ file -- the same defect this release fixes in the duplicate-id message.
248
+ """
249
+ base = sdlc_core.confine_under(root, rel)
250
+ if base is None or base == root.resolve():
251
+ # None: the path escapes the docs root, and the source loop said so.
252
+ # Equal to the root: an empty or dot-only path cell (a source written
253
+ # `#L1-2`, with no file before the locator). Forming a sidecar name
254
+ # from that would step back OUT of the root through `base.parent` and
255
+ # read `<docs-root>.meta.md` -- outside the tree this helper confines.
256
+ return None, rel
257
+ if not base.is_file():
258
+ # The cited file is gone. Its sidecar may still be lying there, but a
259
+ # claim on a missing artifact is already an error from the source loop,
260
+ # and answering from an orphan sidecar would add a second finding about
261
+ # a file that is not there.
262
+ return None, rel
263
+ side = base.parent / (base.name + ".meta.md")
264
+ if side.is_file():
265
+ return (sdlc_core.load_frontmatter(
266
+ sdlc_core.read_text(side).splitlines()) or {}, rel + ".meta.md")
267
+ return (sdlc_core.load_frontmatter(
268
+ sdlc_core.read_text(base).splitlines()) or {}, rel)
269
+
270
+
271
+ def _kb_pointer_resolves(p):
272
+ """A recorded pointer resolves when it names a FILE we can stat.
273
+
274
+ `is_file()` and not `exists()`: `original_path` names a document, so a
275
+ directory that happens to sit at that path is not the original. Any OSError
276
+ is "does not resolve" and never a traceback -- the field points OUTSIDE the
277
+ docs root by design, so the validator has to survive whatever lives there
278
+ (an unreadable parent on a network vault, a name too long, a reparse point).
279
+ """
280
+ try:
281
+ return p.is_file()
282
+ except OSError:
283
+ return False
284
+
285
+
286
+ def _kb_original_candidates(op, root):
287
+ """Every place a recorded `original_path` could legitimately be, in order.
288
+
289
+ `Path.is_absolute()` is NOT the test, and using it was a real defect: on
290
+ Windows a rooted-but-driveless path -- `/vault/manuals/xyz.pdf`, the exact
291
+ form this project's own templates print -- is not absolute, so it was
292
+ joined under the docs root and silently rewritten onto the docs root's
293
+ DRIVE. That produced a warning quoting a path nobody wrote, and could hide
294
+ a genuinely dangling pointer behind a file that happened to exist there.
295
+ Anything EITHER platform calls rooted is now taken as written; only a
296
+ genuinely relative pointer is joined.
297
+
298
+ A relative pointer is tried against the docs root's parent (the project
299
+ root in the standard layout) and against the docs root itself, because
300
+ `--root` and `migrate` both allow a docs root that does not sit directly
301
+ under the project root.
302
+ """
303
+ raw = (op or "").strip()
304
+ forms = [raw]
305
+ if "\\" in raw:
306
+ # A path authored on Windows and read anywhere. Tried as a SECOND form,
307
+ # never instead of the first: a backslash is a legal character in a
308
+ # POSIX filename, and rewriting it unconditionally invented a path.
309
+ forms.append(raw.replace("\\", "/"))
310
+ out, seen = [], set()
311
+ for f in forms:
312
+ cands = ([Path(f)] if (ntpath.isabs(f) or posixpath.isabs(f))
313
+ else [root.parent / f, root / f])
314
+ for c in cands:
315
+ if str(c) not in seen:
316
+ seen.add(str(c))
317
+ out.append(c)
318
+ return out
319
+
320
+
321
+ def kb_check_claims(root):
322
+ """All mechanical claim checks over topics/*.md. Returns (errors, warnings,
323
+ notes) as message lists. Findings only — never per-node status lines."""
324
+ errors, warnings, notes = [], [], []
325
+ topics = root / "topics"
326
+ if not topics.is_dir():
327
+ return errors, warnings, notes
328
+ all_ids = {} # id -> "file:line"
329
+ all_rows = {} # id -> (row, rel)
330
+ # Frontmatter is now resolved for every row, GIVEN included, so a ledger
331
+ # citing one artifact from 80 rows would otherwise stat and decode that
332
+ # artifact's sidecar 80 times. Keyed by the cited path, per run.
333
+ fm_cache = {}
334
+
335
+ def _declaring_frontmatter(rel):
336
+ if rel not in fm_cache:
337
+ fm_cache[rel] = _note_frontmatter(root, rel)
338
+ return fm_cache[rel]
339
+ per_file_rows = []
340
+ for p in sorted(topics.glob("*.md")):
341
+ rel = "topics/" + p.name
342
+ text = sdlc_core.read_text(p)
343
+ rows, perrs = kb_parse_claims(text)
344
+ for line, msg in perrs:
345
+ errors.append("%s:%d: %s" % (rel, line, msg))
346
+ per_file_rows.append((rel, rows))
347
+ for row in rows:
348
+ where = "%s:%d" % (rel, row["_line"])
349
+ # --- source: resolve, confine, span-check ---
350
+ sources = [s.strip() for s in row["source"].split(";") if s.strip()]
351
+ if not sources:
352
+ errors.append("%s: claim has no source" % where)
353
+ continue
354
+ first = sources[0]
355
+ for src in sources:
356
+ if "#" not in src:
357
+ errors.append("%s: source %r has no locator" % (where, src))
358
+ continue
359
+ path_s, loc = src.rsplit("#", 1)
360
+ target = sdlc_core.confine_under(root, path_s)
361
+ if target is None:
362
+ errors.append("%s: source %r escapes the docs root" % (where, src))
363
+ continue
364
+ if not target.is_file():
365
+ errors.append("%s: source %r does not resolve — a claim whose "
366
+ "origin cannot be reopened is model knowledge"
367
+ % (where, src))
368
+ continue
369
+ kb_check_locator(target, loc, where, errors)
370
+ # --- provenance ---
371
+ prov = row["prov"]
372
+ # Resolved ONCE, for every provenance: the non-GIVEN classes read it
373
+ # for their required field, and GIVEN reads it to notice that the
374
+ # artifact declares a weaker chain than the row claims.
375
+ meta, meta_where = (None, "")
376
+ if "#" in first:
377
+ meta, meta_where = _declaring_frontmatter(first.rsplit("#", 1)[0])
378
+ if prov not in PROVENANCES:
379
+ errors.append("%s: prov %r not in %s" % (where, prov, "/".join(PROVENANCES)))
380
+ elif prov in ("DERIVED", "RULING", "ELICITED", "IMPORTED"):
381
+ if meta is None:
382
+ pass # unresolvable source already reported
383
+ elif prov == "DERIVED" and not meta.get("derived_from"):
384
+ errors.append("%s: DERIVED claim's source (%s) carries no "
385
+ "'derived_from:' — model knowledge disguised as "
386
+ "a source" % (where, meta_where))
387
+ elif prov == "RULING" and not meta.get("basis"):
388
+ errors.append("%s: RULING source (%s) carries no 'basis:' — a "
389
+ "preference is not a fact; no basis, no ruling"
390
+ % (where, meta_where))
391
+ elif prov == "IMPORTED" and not meta.get("imported_from"):
392
+ # F-030: IMPORTED exists so a foreign decision cannot pass for
393
+ # a local one. Without the origin the class says nothing and
394
+ # the row is a RULING with the label filed off.
395
+ errors.append("%s: IMPORTED source (%s) carries no "
396
+ "'imported_from:' — the class exists to name "
397
+ "whose decision this was; unnamed, it is a "
398
+ "RULING in disguise" % (where, meta_where))
399
+ elif prov == "GIVEN":
400
+ # F-035: a row resting on an OCR, a transcription or a
401
+ # translation is not the same evidence as one resting on a
402
+ # deterministic text layer. The sidecar could say so only in
403
+ # prose, and prose is not a check -- which is how three rows
404
+ # whose evidence was a reading of an image passed as GIVEN.
405
+ declared = ((meta or {}).get("provenance") or "").strip()
406
+ if declared and declared.upper() != "GIVEN":
407
+ warnings.append(
408
+ "%s: prov GIVEN, but %s declares 'provenance: %s' — the "
409
+ "row reads as first-hand evidence and its artifact does "
410
+ "not. File the row at the provenance the chain actually "
411
+ "has, or correct the sidecar" % (where, meta_where, declared))
412
+ # --- grammar cells ---
413
+ try:
414
+ kb_parse_scope(row["valid"])
415
+ except ValueError as e:
416
+ errors.append("%s: %s" % (where, e))
417
+ try:
418
+ qk = kb_qty_key(row["qty"])
419
+ except ValueError as e:
420
+ errors.append("%s: %s" % (where, e))
421
+ qk = None
422
+ if row["about"] not in ("", "-"):
423
+ m = re.match(r"^([a-z0-9-]+) -> ([a-z0-9-]+)$", row["about"])
424
+ if not m:
425
+ errors.append("%s: about must be '<predicate> -> <slug>' or '-'"
426
+ % where)
427
+ # --- atomicity hint (F-047): a row atomic as provenance can be
428
+ # plural as meaning. Text cannot PROVE plurality, so this is a
429
+ # note — never a warning or an error, never the exit code.
430
+ hints = [h for h in KB_BUNDLE_HINTS if h in row["claim"]]
431
+ if hints:
432
+ notes.append("%s: claim text of %s reads like more than one "
433
+ "assertion (matched %s) — one row asserts one "
434
+ "thing; if it is a bundle, split it "
435
+ "(distillation.md section 3, reconciliation.md "
436
+ "section 'Splitting a bundled row'). Heuristic "
437
+ "only: a genuinely "
438
+ "single assertion needs no change"
439
+ % (where, row["id"] or "(id pending)",
440
+ ", ".join(repr(h) for h in hints)))
441
+ # --- id: recompute, or note fill-pending ---
442
+ if not row["id"]:
443
+ notes.append("%s: id missing — fill-pending, run "
444
+ "'sdlc_check.py claim-id --fill %s'" % (where, rel))
445
+ elif qk is not None and "#" in first:
446
+ path_s, loc = first.rsplit("#", 1)
447
+ expect = kb_claim_id(path_s, loc, qk)
448
+ if row["id"] != expect:
449
+ errors.append("%s: id %s does not recompute from its first "
450
+ "source (+qty) — expected %s; the text may be "
451
+ "corrected freely, the provenance may not be "
452
+ "moved silently" % (where, row["id"], expect))
453
+ if row["id"]:
454
+ if row["id"] in all_ids:
455
+ # F-035: one message served two different defects. Same id
456
+ # with the SAME text is a copied row. Same id with DIFFERENT
457
+ # text is a collision: kb_claim_id hashes path#locator#qty
458
+ # and excludes the text on purpose, so two distinct
459
+ # assertions about one span cannot be told apart. The source
460
+ # and qty do NOT discriminate -- the id already implies them.
461
+ prev_row = all_rows[row["id"]][0]
462
+
463
+ def _cell(r, k):
464
+ return (r[k] or "").strip()
465
+
466
+ def _first_src(r):
467
+ return (r["source"] or "").split(";")[0].strip()
468
+
469
+ same_text = _cell(prev_row, "claim") == _cell(row, "claim")
470
+ same_span = (_first_src(prev_row) == _first_src(row)
471
+ and _cell(prev_row, "qty") == _cell(row, "qty"))
472
+ if same_text:
473
+ errors.append("%s: duplicate id %s (also at %s) — uniqueness "
474
+ "is global across topics/" % (where, row["id"],
475
+ all_ids[row["id"]]))
476
+ elif same_span:
477
+ # F-047: recommending a merge here was a trap — merging
478
+ # distinct assertions manufactures a bundled row, trading
479
+ # this mechanical error for a semantic one no check sees.
480
+ errors.append(
481
+ "%s: id %s collides with the row at %s — two "
482
+ "DIFFERENT rows cite the same span with the same "
483
+ "qty, and the id function cannot separate them (it "
484
+ "hashes path#locator#qty and excludes the text on "
485
+ "purpose). Narrow each locator to the sub-span that "
486
+ "carries its own assertion — narrow spans make "
487
+ "atomic rows id-distinct (on a stored .txt, "
488
+ "p=<page>@<a>-<b> reaches below a line). Merge ONLY "
489
+ "if the two rows state the same single assertion; "
490
+ "merging distinct assertions trades this mechanical "
491
+ "error for a semantic one no check can see — do not "
492
+ "edit the qty to break the tie"
493
+ % (where, row["id"], all_ids[row["id"]]))
494
+ else:
495
+ # Same id, different text AND different span: the id
496
+ # cannot have been computed from both rows, so it was
497
+ # hand-typed or left stale after a source was repointed.
498
+ # Saying "they cite the same span" here would name a
499
+ # cause that is provably not this one.
500
+ errors.append(
501
+ "%s: id %s is also on the row at %s, which cites a "
502
+ "different span — the id was not computed from this "
503
+ "row (hand-typed, or left stale after its source "
504
+ "moved). Run 'sdlc_check.py claim-id --fill %s'"
505
+ % (where, row["id"], all_ids[row["id"]], rel))
506
+ else:
507
+ all_ids[row["id"]] = where
508
+ all_rows[row["id"]] = (row, rel)
509
+ # --- state machine integrity, global ---
510
+ for _, rows in per_file_rows:
511
+ for row in rows:
512
+ if not row["id"]:
513
+ continue
514
+ where = all_ids.get(row["id"], row["id"])
515
+ state = row["state"]
516
+ if state == "OK":
517
+ continue
518
+ m = re.match(r"^(CONTESTED|SUPERSEDED) ([0-9a-f, ]+)$", state)
519
+ if not m:
520
+ errors.append("%s: state must be OK, 'CONTESTED <id>[,..]' or "
521
+ "'SUPERSEDED <id>[,..]': %r" % (where, state))
522
+ continue
523
+ kind = m.group(1)
524
+ targets = [t.strip() for t in m.group(2).split(",") if t.strip()]
525
+ for t in targets:
526
+ other = all_rows.get(t)
527
+ if other is None:
528
+ errors.append("%s: %s points at id %s which resolves to no row "
529
+ "— deleting one side of a disagreement breaks "
530
+ "the check, it does not clean up" % (where, kind, t))
531
+ continue
532
+ orow, _ = other
533
+ if kind == "SUPERSEDED" and orow["prov"] == "IMPORTED":
534
+ # F-030, owner ruling 2026-08-03: knowledge crosses a project
535
+ # boundary, authority does not. An IMPORTED row carries another
536
+ # owner's decision; letting it supersede a local row would make
537
+ # that decision binding here without anyone here granting it.
538
+ # Re-ratify first: write your own ruling note with your own
539
+ # basis and flip the row to RULING.
540
+ errors.append("%s: SUPERSEDED by %s, which is IMPORTED — a "
541
+ "foreign decision cannot settle a local row. "
542
+ "Re-ratify it (own note, own 'basis:', prov "
543
+ "RULING) or resolve this some other way"
544
+ % (where, t))
545
+ if kind == "CONTESTED":
546
+ if orow["state"].startswith("SUPERSEDED"):
547
+ errors.append("%s: CONTESTED points at SUPERSEDED row %s — "
548
+ "the set must be rewritten when a member is "
549
+ "superseded" % (where, t))
550
+ elif not (orow["state"].startswith("CONTESTED")
551
+ and row["id"] in orow["state"]):
552
+ errors.append("%s: CONTESTED is not symmetric — %s does not "
553
+ "name %s back; one flipped cell must never "
554
+ "silently end a disagreement"
555
+ % (where, t, row["id"]))
556
+ return errors, warnings, notes
557
+
558
+
559
+ def kb_check_locator(target, loc, where, errors):
560
+ """A locator must address an existing span of the stored bytes."""
561
+ m = LOC_PAGE_RE.match(loc)
562
+ if m:
563
+ page, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
564
+ if a >= b:
565
+ errors.append("%s: locator %r has an empty span" % (where, loc))
566
+ return
567
+ # offsets address the stored extraction (<stem>.txt beside the original)
568
+ ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
569
+ if not ext.is_file():
570
+ errors.append("%s: no stored extraction %s for locator %r — offsets "
571
+ "must address kept bytes" % (where, ext.name, loc))
572
+ return
573
+ pages = sdlc_core.read_text(ext).split("\f")
574
+ if page < 1 or page > len(pages):
575
+ errors.append("%s: locator %r addresses page %d of %d" %
576
+ (where, loc, page, len(pages)))
577
+ elif b > len(pages[page - 1]):
578
+ errors.append("%s: locator %r spans past the end of page %d "
579
+ "(%d chars)" % (where, loc, page, len(pages[page - 1])))
580
+ return
581
+ m = LOC_LINE_RE.match(loc)
582
+ if m:
583
+ a, b = int(m.group(1)), int(m.group(2))
584
+ if a > b or a < 1:
585
+ errors.append("%s: locator %r has an invalid line range" % (where, loc))
586
+ return
587
+ n = sdlc_core.read_text(target).count("\n") + 1
588
+ if b > n:
589
+ errors.append("%s: locator %r spans past line %d" % (where, loc, n))
590
+ return
591
+ if not LOC_CELL_RE.match(loc):
592
+ errors.append("%s: locator %r matches no known form "
593
+ "(p=<n>@<a>-<b> / L<a>-<b> / Sheet<s>!<cell>)" % (where, loc))
594
+
595
+
596
+ def kb_anchor_pattern(phrase, ignore_case=False):
597
+ """A phrase, compiled so it survives the line wraps an extraction introduces.
598
+
599
+ Every run of whitespace in the phrase becomes `\\s+`: a PDF extraction breaks
600
+ phrases mid-line, so a literal space matches nothing while a probe that
601
+ pretty-prints collapsed whitespace shows the phrase intact. That gap cost a
602
+ field user two full generation rounds."""
603
+ tokens = [t for t in re.split(r"\s+", phrase.strip()) if t]
604
+ if not tokens:
605
+ return None
606
+ body = r"\s+".join(re.escape(t) for t in tokens)
607
+ return re.compile(body, re.IGNORECASE if ignore_case else 0)
608
+
609
+
610
+ def kb_resolve_anchor(target, phrase, ignore_case=False, page=None):
611
+ """Locate a phrase in stored bytes and return [(locator, context), ...].
612
+
613
+ The stored form decides the locator form: form-feeds mean paged bytes and
614
+ offsets are counted inside the page, exactly as `kb_check_locator` reads
615
+ them; otherwise line numbers. Every locator produced here is re-verified with
616
+ that same checker before it is returned, so this can never emit a span its
617
+ own validator would reject."""
618
+ rx = kb_anchor_pattern(phrase, ignore_case)
619
+ if rx is None:
620
+ return []
621
+ # Read what the checker will read: for a non-text original the stored
622
+ # extraction beside it holds the bytes offsets address. `target` stays as
623
+ # given, because that is the path the claim's `source` cell will carry and
624
+ # therefore the path the round-trip below must verify.
625
+ ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
626
+ text = sdlc_core.read_text(ext if ext.is_file() else target)
627
+ hits = []
628
+ if "\f" in text:
629
+ for idx, body in enumerate(text.split("\f"), start=1):
630
+ if page is not None and idx != page:
631
+ continue
632
+ for m in rx.finditer(body):
633
+ hits.append(("p=%d@%d-%d" % (idx, m.start(), m.end()),
634
+ body[max(0, m.start() - 40):m.end() + 40]))
635
+ else:
636
+ lines = text.splitlines()
637
+ starts, pos = [], 0
638
+ for ln in lines:
639
+ starts.append(pos)
640
+ pos += len(ln) + 1
641
+ for m in rx.finditer(text):
642
+ a = sum(1 for s in starts if s <= m.start())
643
+ b = sum(1 for s in starts if s <= m.end() - 1)
644
+ hits.append(("L%d-%d" % (a, b),
645
+ text[max(0, m.start() - 40):m.end() + 40]))
646
+ verified = []
647
+ for loc, ctx in hits:
648
+ errs = []
649
+ kb_check_locator(target, loc, "anchor", errs)
650
+ if not errs:
651
+ verified.append((loc, ctx))
652
+ return verified
653
+
654
+
655
+ # ---------------------------------------------------------------- topic graph
656
+ # F-024. Findings only; the graph is held in memory, rebuilt per run.
657
+
658
+ def kb_load_topics(root):
659
+ """topics/*.md -> {slug: meta+body info}. The files ARE the state."""
660
+ nodes = {}
661
+ topics = root / "topics"
662
+ if not topics.is_dir():
663
+ return nodes
664
+ for p in sorted(topics.glob("*.md")):
665
+ if p.name == "INDEX.md":
666
+ continue
667
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
668
+ slug = (meta.get("topic") or p.stem).strip()
669
+ nodes[slug] = {"meta": meta, "file": "topics/" + p.name}
670
+ return nodes
671
+
672
+
673
+ def _as_list(v):
674
+ if v is None:
675
+ return []
676
+ if isinstance(v, str):
677
+ v = v.strip()
678
+ if v.startswith("[") and v.endswith("]"):
679
+ return [x.strip() for x in v[1:-1].split(",") if x.strip()]
680
+ return [v] if v else []
681
+ return list(v)
682
+
683
+
684
+ def kb_graph_check(root):
685
+ """Integrity of the topic graph. Errors/warnings only — never a per-node
686
+ status line (the work-management Non-Goal forbids the collected surface)."""
687
+ errors, warnings = [], []
688
+ nodes = kb_load_topics(root)
689
+ if not nodes:
690
+ return errors, warnings
691
+ import difflib
692
+ live = {s: n for s, n in nodes.items()
693
+ if (n["meta"].get("status") or "").strip() != "SUPERSEDED"}
694
+
695
+ def resolve(slug):
696
+ """Follow tombstone redirects to a live slug, or None."""
697
+ seen = set()
698
+ while slug in nodes and slug not in seen:
699
+ seen.add(slug)
700
+ n = nodes[slug]
701
+ if (n["meta"].get("status") or "").strip() == "SUPERSEDED":
702
+ slug = (n["meta"].get("redirect_to") or "").strip()
703
+ continue
704
+ return slug
705
+ return None
706
+
707
+ owns_seen = {}
708
+ for slug, n in nodes.items():
709
+ rel = n["file"]
710
+ if not SLUG_RE.match(slug):
711
+ errors.append("%s: slug %r fails the grammar ^[a-z0-9][a-z0-9-]{0,63}$"
712
+ % (rel, slug))
713
+ if (n["meta"].get("status") or "").strip() == "SUPERSEDED":
714
+ tgt = (n["meta"].get("redirect_to") or "").strip()
715
+ if not tgt or resolve(tgt) is None:
716
+ errors.append("%s: tombstone redirect_to %r resolves to no live node"
717
+ % (rel, tgt))
718
+ continue
719
+ for parent in _as_list(n["meta"].get("parents")):
720
+ if not SLUG_RE.match(parent):
721
+ errors.append("%s: parent %r fails the slug grammar" % (rel, parent))
722
+ elif resolve(parent) is None:
723
+ errors.append("%s: parent %r resolves to no live node" % (rel, parent))
724
+ for c in _as_list(n["meta"].get("owns")):
725
+ if not OWNS_RE.match(c):
726
+ errors.append("%s: owns entry %r fails the grammar "
727
+ "<slug>/<concept>" % (rel, c))
728
+ elif c in owns_seen:
729
+ errors.append("%s: concept %r owned twice (also by %s) — one owner "
730
+ "per concept" % (rel, c, owns_seen[c]))
731
+ else:
732
+ owns_seen[c] = rel
733
+ rl = (n["meta"].get("related") or "").strip()
734
+ if rl and resolve(rl) is None:
735
+ errors.append("%s: related %r resolves to no live node" % (rel, rl))
736
+ # cycles + reachability on primary parents, live nodes only
737
+ roots = [s for s, n in live.items() if not _as_list(n["meta"].get("parents"))]
738
+ for slug, n in live.items():
739
+ seen = set()
740
+ cur = slug
741
+ while cur is not None:
742
+ if cur in seen:
743
+ errors.append("%s: cycle through %r — a detached ring is invisible "
744
+ "to descent forever" % (n["file"], cur))
745
+ break
746
+ seen.add(cur)
747
+ parents = _as_list(live.get(cur, {}).get("meta", {}).get("parents")) \
748
+ if cur in live else []
749
+ cur = resolve(parents[0]) if parents else None
750
+ if roots:
751
+ reachable = set()
752
+ frontier = list(roots)
753
+ children = {}
754
+ for s, n in live.items():
755
+ for parent in _as_list(n["meta"].get("parents")):
756
+ rp = resolve(parent)
757
+ if rp:
758
+ children.setdefault(rp, []).append(s)
759
+ while frontier:
760
+ cur = frontier.pop()
761
+ if cur in reachable:
762
+ continue
763
+ reachable.add(cur)
764
+ frontier.extend(children.get(cur, []))
765
+ for slug, n in live.items():
766
+ if slug not in reachable and slug != "unplaced":
767
+ errors.append("%s: unreachable from any root — descent is the only "
768
+ "retrieval path, so this node is lost, not odd"
769
+ % n["file"])
770
+ # near-duplicate warning: catches listino/listini, not listino/pricing
771
+ slugs = sorted(live)
772
+ for i, a in enumerate(slugs):
773
+ for b in slugs[i + 1:]:
774
+ pa = _as_list(live[a]["meta"].get("parents"))
775
+ pb = _as_list(live[b]["meta"].get("parents"))
776
+ if pa and pa == pb and difflib.SequenceMatcher(None, a, b).ratio() > 0.85:
777
+ warnings.append("%s and %s: same parents and >0.85 name similarity — "
778
+ "possible duplicate; the semantic case belongs to "
779
+ "the router evals" % (a, b))
780
+ return errors, warnings
781
+
782
+
783
+ def kb_build_topic_index(root):
784
+ """slug | description | parents | synonyms — a router, not a status board."""
785
+ nodes = kb_load_topics(root)
786
+ lines = ["# Topic Index", "",
787
+ "<!-- GENERATED by sdlc_check.py index - do not edit by hand -->", "",
788
+ "| topic | description | parents | synonyms |",
789
+ "|---|---|---|---|"]
790
+ for slug in sorted(nodes):
791
+ n = nodes[slug]
792
+ if (n["meta"].get("status") or "").strip() == "SUPERSEDED":
793
+ continue
794
+ lines.append("| %s | %s | %s | %s |" % (
795
+ slug, (n["meta"].get("description") or "").strip(),
796
+ ", ".join(_as_list(n["meta"].get("parents"))),
797
+ ", ".join(_as_list(n["meta"].get("synonyms")))))
798
+ return "\n".join(lines) + "\n"
799
+
800
+
801
+ def kb_parse_extracted_through(value):
802
+ """('complete', None) | ('p', n) | ('L', n), or None when the value is not a
803
+ coverage statement.
804
+
805
+ Fail-closed on purpose: a field whose whole job is to be checkable must be
806
+ checkable, so an unreadable value is an error rather than a silent pass."""
807
+ m = EXTRACTED_THROUGH_RE.match((value or "").strip())
808
+ if not m:
809
+ return None
810
+ if m.group(1):
811
+ return "p", int(m.group(1))
812
+ if m.group(2):
813
+ return "L", int(m.group(2))
814
+ return "complete", None
815
+
816
+
817
+ def kb_extraction_extent(artifact, kind):
818
+ """How far the stored bytes go, in the unit `kind` — or None when nothing
819
+ measurable is stored.
820
+
821
+ Opens exactly the file `kb_check_locator` opens for that locator form: pages
822
+ live in the stored extraction beside the original, lines in the artifact
823
+ itself. Coverage is therefore measured against the same bytes a locator
824
+ addresses, and a binary is never read as text (a .pdf with no extraction
825
+ beside it is simply unmeasurable — the p= branch needs the .txt)."""
826
+ if kind == "p":
827
+ ext = artifact if artifact.suffix == ".txt" else artifact.with_suffix(".txt")
828
+ if not ext.is_file():
829
+ return None
830
+ return len(sdlc_core.read_text(ext).split("\f"))
831
+ if kind == "L":
832
+ if not artifact.is_file():
833
+ return None
834
+ return sdlc_core.read_text(artifact).count("\n") + 1
835
+ return None
836
+
837
+
838
+ def kb_coverage_cell(artifact, through):
839
+ """The coverage fact for one corpus row.
840
+
841
+ EVERY artifact gets one, including the finished ones: printing only the
842
+ incomplete ones would turn this index into 'the set that is not current',
843
+ which the Vision refuses (r9). It is a fact on an existing row, never a
844
+ filter and never a sort key."""
845
+ if not (through or "").strip():
846
+ return "extraction not recorded"
847
+ parsed = kb_parse_extracted_through(through)
848
+ if parsed is None:
849
+ return "extracted through %s (unreadable)" % through.strip()
850
+ kind, n = parsed
851
+ if kind == "complete":
852
+ return "extracted through complete"
853
+ total = kb_extraction_extent(artifact, kind)
854
+ stated = ("p=%d" % n) if kind == "p" else ("L%d" % n)
855
+ return "extracted through %s of %d" % (stated, total) if total \
856
+ else "extracted through %s" % stated
857
+
858
+
859
+ def kb_build_corpus_index(root):
860
+ """One row per corpus artifact, from sidecars and note frontmatter."""
861
+ corpus = root / "corpus"
862
+ lines = ["# Corpus Index", "",
863
+ "<!-- GENERATED by sdlc_check.py index - do not edit by hand -->", ""]
864
+ given = corpus / "given"
865
+ if given.is_dir():
866
+ lines.append("## given/")
867
+ for meta_p in sorted(given.glob("*.meta.md")):
868
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
869
+ orig = meta_p.name[:-len(".meta.md")]
870
+ sup = kb_unquote(meta.get("supersedes"))
871
+ lines.append("- `%s` — %s%s — %s" % (
872
+ orig, (meta.get("date") or "undated"),
873
+ (" — supersedes `%s`" % sup) if sup else "",
874
+ kb_coverage_cell(given / orig, meta.get("extracted_through"))))
875
+ notes = corpus / "notes"
876
+ if notes.is_dir():
877
+ lines.append("")
878
+ lines.append("## notes/")
879
+ for p in sorted(notes.glob("*.md")):
880
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
881
+ origin = (meta.get("origin") or
882
+ ("derived" if meta.get("derived_from") else
883
+ ("ruling" if meta.get("basis") else "unknown")))
884
+ lines.append("- `%s` — %s" % (p.name, origin))
885
+ return "\n".join(lines) + "\n"
886
+
887
+
888
+ def kb_cited_extents(root):
889
+ """Per artifact file name, what the claim rows say about it: the highest page
890
+ and the highest line any locator addresses (with the row that says so), and
891
+ every row citing it. One walk of topics/, shared by the supersession and the
892
+ coverage checks — two walks of the same tree for two questions is how the
893
+ answers start disagreeing."""
894
+ cited = {}
895
+ topics = root / "topics"
896
+ if not topics.is_dir():
897
+ return cited
898
+ for p in sorted(topics.glob("*.md")):
899
+ for row in kb_parse_claims(sdlc_core.read_text(p))[0]:
900
+ where = "topics/%s:%d" % (p.name, row["_line"])
901
+ for src in row["source"].split(";"):
902
+ src = src.strip()
903
+ if not src:
904
+ continue
905
+ path_s, loc = src.rsplit("#", 1) if "#" in src else (src, "")
906
+ parts = Path(path_s.strip().replace("\\", "/")).parts
907
+ if parts[-3:-1] != ("corpus", "given"):
908
+ # Keyed by file name, so a note sharing a name with an
909
+ # artifact would otherwise be attributed to it and inflate
910
+ # its extents. Both consumers here ask only about given/.
911
+ continue
912
+ name = parts[-1]
913
+ e = cited.setdefault(name, {"p": 0, "p_where": None,
914
+ "L": 0, "L_where": None, "rows": []})
915
+ e["rows"].append(where)
916
+ m = LOC_PAGE_RE.match(loc.strip())
917
+ if m and int(m.group(1)) > e["p"]:
918
+ e["p"], e["p_where"] = int(m.group(1)), where
919
+ continue
920
+ m = LOC_LINE_RE.match(loc.strip())
921
+ if m and int(m.group(2)) > e["L"]:
922
+ e["L"], e["L_where"] = int(m.group(2)), where
923
+ return cited
924
+
925
+
926
+ def _kb_cited_for(cited, artifact_name):
927
+ """Claims may cite the original or its stored extraction — both address the
928
+ same bytes, so both count as citing this artifact."""
929
+ names = [artifact_name]
930
+ if not artifact_name.endswith(".txt"):
931
+ names.append(Path(artifact_name).with_suffix(".txt").name)
932
+ found = [cited[n] for n in names if n in cited]
933
+ if not found:
934
+ return None
935
+ merged = dict(found[0])
936
+ for e in found[1:]:
937
+ for kind in ("p", "L"):
938
+ if e[kind] > merged[kind]:
939
+ merged[kind], merged[kind + "_where"] = e[kind], e[kind + "_where"]
940
+ merged["rows"] = merged["rows"] + e["rows"]
941
+ return merged
942
+
943
+
944
+ def kb_check_coverage(rel, artifact, through, facts, errors, warnings):
945
+ """`extracted_through:` against the rows and against the stored bytes (F-031).
946
+
947
+ Four outcomes, and the boundary between them is the whole point: claims with
948
+ no field errors (an unfalsifiable 'done'); a field that contradicts the bytes
949
+ or the rows errors; a field short of the end warns, because partial work is
950
+ legal mid-ingestion; an artifact nobody extracted from stays silent.
951
+
952
+ The limit, stated where the code is: nothing here proves a page was READ. A
953
+ field advanced without extracting is invisible to any checker, since a page
954
+ that asserts nothing legitimately yields no rows — that direction belongs to
955
+ the ingestion review. What this buys is that the shortcut must be written
956
+ down to pass."""
957
+ if not through:
958
+ if facts:
959
+ errors.append("%s: claims cite this artifact and the sidecar has no "
960
+ "'extracted_through:' — how far a source was read is an "
961
+ "assertion like any other, and unstated 'I am finished' "
962
+ "cannot be falsified. Record it: 'p=<n>', 'L<n>', or "
963
+ "'complete' (first row at %s)" % (rel, facts["rows"][0]))
964
+ return
965
+ parsed = kb_parse_extracted_through(through)
966
+ if parsed is None:
967
+ errors.append("%s: extracted_through: %r is not a coverage statement — use "
968
+ "'complete', 'p=<n>' or 'L<n>'" % (rel, through))
969
+ return
970
+ kind, n = parsed
971
+ if kind == "complete":
972
+ return
973
+ other = "L" if kind == "p" else "p"
974
+ unit = "pages" if kind == "p" else "lines"
975
+ total = kb_extraction_extent(artifact, kind)
976
+ if facts and facts[other] and not facts[kind]:
977
+ errors.append("%s: coverage is stated in %s while every claim addresses %s "
978
+ "(%s) — stated in the wrong unit it compares with nothing, "
979
+ "and nothing here is checkable"
980
+ % (rel, unit, "lines" if kind == "p" else "pages",
981
+ facts[other + "_where"]))
982
+ return
983
+ if total and n > total:
984
+ errors.append("%s: extracted_through: %s, past the end of the stored bytes "
985
+ "(%d %s) — coverage cannot exceed what was stored"
986
+ % (rel, through, total, unit))
987
+ elif total and n < total:
988
+ warnings.append("%s: extracted through %s of %d %s — ingestion is incomplete "
989
+ "(legal mid-work: a source is finished when every page has "
990
+ "been read, not when enough rows exist)"
991
+ % (rel, through, total, unit))
992
+ if facts and facts[kind] > n:
993
+ reached = ("p=%d" % facts[kind]) if kind == "p" else ("L%d" % facts[kind])
994
+ errors.append("%s: a claim addresses %s, past the declared coverage %s (%s) — "
995
+ "the sidecar and the rows contradict each other; one of the two "
996
+ "is wrong" % (rel, reached, through, facts[kind + "_where"]))
997
+
998
+
999
+ def kb_corpus_check(root):
1000
+ """Corpus integrity: digests, supersession, coverage, laundered notes.
1001
+ Findings only."""
1002
+ errors, warnings = [], []
1003
+ corpus = root / "corpus"
1004
+ if not corpus.is_dir():
1005
+ return errors, warnings
1006
+ cited = kb_cited_extents(root)
1007
+ superseded = set()
1008
+ given = corpus / "given"
1009
+ if given.is_dir():
1010
+ for meta_p in sorted(given.glob("*.meta.md")):
1011
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
1012
+ orig = given / meta_p.name[:-len(".meta.md")]
1013
+ rel = "corpus/given/" + orig.name
1014
+ if not orig.is_file():
1015
+ errors.append("%s: sidecar exists but the original is gone" % rel)
1016
+ continue
1017
+ recorded = (meta.get("sha256") or "").strip()
1018
+ if recorded:
1019
+ actual = kb_sha256_bytes(orig)
1020
+ if actual != recorded:
1021
+ errors.append("%s: raw-byte digest changed since ingest — "
1022
+ "given/ is never edited; this is the check, "
1023
+ "not a convention" % rel)
1024
+ # F-035: `original_sha256` is not verified because we do not hold
1025
+ # the bytes -- a limit stated out loud in distillation.md. That
1026
+ # reason does not extend to the PATH, which costs one exists(). A
1027
+ # corpus whose premise is "every provenance is a real file" cannot
1028
+ # let 16 sidecars go dangling behind a green run.
1029
+ op = kb_unquote(meta.get("original_path"))
1030
+ if op:
1031
+ cands = _kb_original_candidates(op, root)
1032
+ tried = [str(c) for c in cands]
1033
+ resolved = any(_kb_pointer_resolves(c) for c in cands)
1034
+ if not resolved:
1035
+ warnings.append(
1036
+ "%s: original_path %r does not resolve (tried %s) — the "
1037
+ "extraction is intact, the pointer to the original is "
1038
+ "not. A warning and not an error: a bundle carries "
1039
+ "artifacts and sidecars, never the originals, so after "
1040
+ "an import this dangles legitimately"
1041
+ % (rel, op, ", ".join(tried)))
1042
+ sup = kb_unquote(meta.get("supersedes"))
1043
+ if sup:
1044
+ superseded.add(sup)
1045
+ if not (given / sup).is_file():
1046
+ warnings.append("%s: supersedes %r which is not in given/"
1047
+ % (rel, sup))
1048
+ kb_check_coverage(rel, orig,
1049
+ (meta.get("extracted_through") or "").strip(),
1050
+ _kb_cited_for(cited, orig.name), errors, warnings)
1051
+ notes = corpus / "notes"
1052
+ if notes.is_dir():
1053
+ for p in sorted(notes.glob("*.md")):
1054
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
1055
+ if not (meta.get("derived_from") or meta.get("origin")
1056
+ or meta.get("basis")):
1057
+ errors.append("corpus/notes/%s: neither 'derived_from:' nor "
1058
+ "'origin:' nor 'basis:' — model knowledge disguised "
1059
+ "as a source" % p.name)
1060
+ # claims resting on superseded originals (UC4), from the same single walk
1061
+ for name in sorted(superseded):
1062
+ for where in (cited.get(name) or {}).get("rows", []):
1063
+ warnings.append("%s: claim rests on %s, which a newer version "
1064
+ "supersedes — re-verify or re-place" % (where, name))
1065
+ return errors, warnings
1066
+
1067
+
1068
+ def kb_sha256_bytes(path):
1069
+ """RAW-byte digest for binaries. The spine's sha256_file normalizes CRLF->LF
1070
+ (right for text snapshots, wrong for binaries, where a hostile 0D0A/0A pair
1071
+ would collide)."""
1072
+ h = hashlib.sha256()
1073
+ with open(path, "rb") as f:
1074
+ for chunk in iter(lambda: f.read(65536), b""):
1075
+ h.update(chunk)
1076
+ return h.hexdigest()
1077
+
1078
+
1079
+ # -------------------------------------------------------- time cycle (F-044)
1080
+ # The three lists of the kb time cycle, computed ONCE per invocation and fed to
1081
+ # every surface (check/graph/corpus warn streams, `stale`, orient) -- never a
1082
+ # leaner re-implementation per surface, which is how under-counting is born.
1083
+
1084
+ KB_GENERATED_RE = re.compile(r"GENERATED by \S+ index - do not edit by hand")
1085
+ KB_HEX12_RE = re.compile(r"^[0-9a-f]{12}$")
1086
+
1087
+
1088
+ def _kb_hex_bounded(text, cid):
1089
+ """True when cid appears NOT embedded in a longer hex run: a git hash
1090
+ sharing a substring must never count as a citation (either side, either
1091
+ case). cid is pre-validated 12-hex, so it is regex-safe by construction."""
1092
+ return re.search(r"(?<![0-9a-fA-F])%s(?![0-9a-fA-F])" % cid,
1093
+ text) is not None
1094
+
1095
+
1096
+ def _kb_df_entries(value):
1097
+ """A derived_from cell -> its entries: a single value or the inline list
1098
+ `[a, b]`, each kb_unquote'd (quoted paths with spaces are legal YAML)."""
1099
+ v = (value or "").strip()
1100
+ if not v:
1101
+ return []
1102
+ if v.startswith("[") and v.endswith("]"):
1103
+ return [kb_unquote(e) for e in v[1:-1].split(",") if kb_unquote(e)]
1104
+ return [kb_unquote(v)]
1105
+
1106
+
1107
+ def kb_time_cycle(docs):
1108
+ """-> {"cited": [(doc_rel, old_id, missing_ids, succ_ids)],
1109
+ "expired": [(topic_rel, id, until, cited_by)],
1110
+ "chain": [(node_rel, ground_rel, via)],
1111
+ "dangling": [(owner_rel, raw_entry)]} -- each list sorted.
1112
+
1113
+ Read-only, tolerant per file: a malformed table or frontmatter drops that
1114
+ file's contribution (the syntax finding belongs to the claims/corpus
1115
+ checks). cited: a LIVING document containing a SUPERSEDED id without every
1116
+ successor id visible in the same document -- the self-clearing condition,
1117
+ so an append-only Diary correction naming the successors is a legal fix.
1118
+ Living excludes corpus/given/ (+ sidecars: remedy-free), generated files
1119
+ (header marker, ANY lens's entry point), and SUPERSEDED tombstones; the
1120
+ OWNING topic self-clears by construction (its state cell carries every
1121
+ successor id). expired: `valid` windows whose half-open `until` has
1122
+ passed -- computed here, NEVER written back (state writes belong to
1123
+ reconciliation). chain: transitive reverse derived_from closure from
1124
+ superseded given/ artifacts, edges from BOTH carriers (note frontmatter
1125
+ and given/*.meta.md sidecars). dangling: any derived_from entry that
1126
+ resolves to nothing -- standing, supersession or not: a chain must be
1127
+ walkable BEFORE anything falls."""
1128
+ import datetime as _dt
1129
+ import posixpath
1130
+ cited, expired, chain, dangling = [], [], [], []
1131
+ superseded = {}
1132
+ windows = []
1133
+ topics = docs / "topics"
1134
+ if topics.is_dir():
1135
+ for p in sorted(topics.glob("*.md")):
1136
+ if p.name == "INDEX.md":
1137
+ continue
1138
+ try:
1139
+ rows, _ = kb_parse_claims(sdlc_core.read_text(p))
1140
+ except Exception:
1141
+ continue
1142
+ rel = "topics/" + p.name
1143
+ for row in rows:
1144
+ rid = (row.get("id") or "").strip()
1145
+ if not KB_HEX12_RE.match(rid):
1146
+ continue
1147
+ m = re.match(r"^SUPERSEDED ([0-9a-f, ]+)$",
1148
+ (row.get("state") or "").strip())
1149
+ if m:
1150
+ succs = tuple(x.strip() for x in m.group(1).split(",")
1151
+ if KB_HEX12_RE.match(x.strip()))
1152
+ if succs:
1153
+ superseded[rid] = succs
1154
+ try:
1155
+ scope = kb_parse_scope(row.get("valid") or "-")
1156
+ except Exception:
1157
+ continue
1158
+ if scope[0] == "window" and scope[2]:
1159
+ windows.append((rel, rid, scope[2]))
1160
+ living = []
1161
+ for p in sorted(docs.rglob("*.md")):
1162
+ rel = p.relative_to(docs).as_posix()
1163
+ if rel.startswith("corpus/given/"):
1164
+ continue
1165
+ try:
1166
+ text = sdlc_core.read_text(p)
1167
+ except Exception:
1168
+ continue
1169
+ if KB_GENERATED_RE.search(text[:512]):
1170
+ continue
1171
+ meta = sdlc_core.load_frontmatter(text.splitlines()) or {}
1172
+ if (meta.get("status") or "").strip() == "SUPERSEDED":
1173
+ continue
1174
+ living.append((rel, text))
1175
+ for rel, text in living:
1176
+ for old in sorted(superseded):
1177
+ if _kb_hex_bounded(text, old):
1178
+ succs = superseded[old]
1179
+ missing = tuple(x for x in succs
1180
+ if not _kb_hex_bounded(text, x))
1181
+ if missing:
1182
+ cited.append((rel, old, missing, succs))
1183
+ today = _dt.date.today().isoformat()
1184
+ for topic_rel, rid, until in sorted(windows):
1185
+ if until <= today:
1186
+ by = tuple(rel for rel, text in living
1187
+ if rel != topic_rel and _kb_hex_bounded(text, rid))
1188
+ expired.append((topic_rel, rid, until, by))
1189
+ rev = {}
1190
+ node_meta = {}
1191
+ sup_arts = set()
1192
+ notes_dir = docs / "corpus" / "notes"
1193
+ given_dir = docs / "corpus" / "given"
1194
+
1195
+ def _resolve(entry):
1196
+ # Normalize BEFORE resolving: `./x`, `a//b`, `a/../b` resolve on the
1197
+ # filesystem but would never match the canonical node keys, so the
1198
+ # walk would silently stop with neither a chain entry nor a dangling
1199
+ # warn (closure-review WARN-1). Backslashed spellings stay loud: no
1200
+ # `/` means the bare-name ladder, which dangles them visibly.
1201
+ entry = posixpath.normpath(entry)
1202
+ if "/" in entry:
1203
+ return entry if (docs / entry).is_file() else None
1204
+ for base in ("corpus/notes/", "corpus/given/"):
1205
+ if (docs / (base + entry)).is_file():
1206
+ return base + entry
1207
+ return None
1208
+
1209
+ def _edges_of(owner_rel, meta):
1210
+ for e in _kb_df_entries(meta.get("derived_from")):
1211
+ target = _resolve(e)
1212
+ if target is None:
1213
+ dangling.append((owner_rel, e))
1214
+ else:
1215
+ rev.setdefault(target, set()).add(owner_rel)
1216
+
1217
+ if notes_dir.is_dir():
1218
+ for p in sorted(notes_dir.glob("*.md")):
1219
+ rel = "corpus/notes/" + p.name
1220
+ try:
1221
+ meta = sdlc_core.load_frontmatter(
1222
+ sdlc_core.read_text(p).splitlines()) or {}
1223
+ except Exception:
1224
+ continue
1225
+ node_meta[rel] = meta
1226
+ _edges_of(rel, meta)
1227
+ if given_dir.is_dir():
1228
+ for mp in sorted(given_dir.glob("*.meta.md")):
1229
+ orig_rel = "corpus/given/" + mp.name[:-len(".meta.md")]
1230
+ try:
1231
+ meta = sdlc_core.load_frontmatter(
1232
+ sdlc_core.read_text(mp).splitlines()) or {}
1233
+ except Exception:
1234
+ continue
1235
+ node_meta[orig_rel] = meta
1236
+ _edges_of(orig_rel, meta)
1237
+ sup = kb_unquote(meta.get("supersedes"))
1238
+ if sup:
1239
+ sup_arts.add("corpus/given/" + sup)
1240
+ for ground in sorted(sup_arts):
1241
+ seen = {ground}
1242
+ frontier = [(ground, ())]
1243
+ while frontier:
1244
+ node, via = frontier.pop()
1245
+ for owner in sorted(rev.get(node, ())):
1246
+ if owner in seen:
1247
+ continue
1248
+ seen.add(owner)
1249
+ owner_via = via + (node,)
1250
+ tomb = (node_meta.get(owner, {}).get("status")
1251
+ or "").strip() == "SUPERSEDED"
1252
+ if owner not in sup_arts and not tomb:
1253
+ chain.append((owner, ground, owner_via))
1254
+ frontier.append((owner, owner_via))
1255
+ cited.sort()
1256
+ expired.sort()
1257
+ chain.sort()
1258
+ dangling.sort()
1259
+ return {"cited": cited, "expired": expired,
1260
+ "chain": chain, "dangling": dangling}
1261
+
1262
+
1263
+ def kb_time_cycle_warns(cycle):
1264
+ """-> (cited_lines, chain_lines, dangling_lines). ASCII only."""
1265
+ cited = ["%s: cites %s, superseded by %s -- successor(s) %s not named; "
1266
+ "revise per the Revision doctrine"
1267
+ % (rel, old, ", ".join(succs), ", ".join(missing))
1268
+ for rel, old, missing, succs in cycle["cited"]]
1269
+ chain = []
1270
+ for node, ground, via in cycle["chain"]:
1271
+ tail = ("supersede the note" if node.startswith("corpus/notes/")
1272
+ else "supersede the derivation")
1273
+ chain.append("%s: derives (via %s) from %s, which a newer version "
1274
+ "supersedes -- re-derive from the successor or %s"
1275
+ % (node, " -> ".join(via), ground, tail))
1276
+ dang = ["%s: derived_from %s resolves to nothing -- the chain is not "
1277
+ "walkable" % (o, e) for o, e in cycle["dangling"]]
1278
+ return cited, chain, dang
1279
+
1280
+
1281
+ # ------------------------------------------------------- portability (F-030)
1282
+ # Export a subgraph WITH the bytes its claims cite; import it additively.
1283
+ #
1284
+ # The bundle mirrors the docs-root layout on purpose: claim `source` cells are
1285
+ # docs-root-relative, so nothing is rewritten on import and `kb_claim_id` --
1286
+ # sha256(path#locator#qty), text excluded -- mints the SAME id in both projects.
1287
+ # That is what makes de-duplication mechanical instead of a judgement call, and
1288
+ # it is why this feature is small.
1289
+
1290
+ BUNDLE_MANIFEST = "MANIFEST.md"
1291
+
1292
+
1293
+ def kb_claim_sources(row):
1294
+ """The docs-root-relative artifact paths a claim row cites (no locators)."""
1295
+ out = []
1296
+ for src in row["source"].split(";"):
1297
+ src = src.strip()
1298
+ if src and "#" in src:
1299
+ out.append(src.rsplit("#", 1)[0])
1300
+ elif src:
1301
+ out.append(src)
1302
+ return out
1303
+
1304
+
1305
+ def kb_collect_topics(docs):
1306
+ """{slug: (path, text, rows)} for every topic node that parses."""
1307
+ out = {}
1308
+ tdir = docs / "topics"
1309
+ if not tdir.is_dir():
1310
+ return out
1311
+ for p in sorted(tdir.glob("*.md")):
1312
+ if p.name == "INDEX.md":
1313
+ continue
1314
+ text = sdlc_core.read_text(p)
1315
+ meta = sdlc_core.load_frontmatter(text.splitlines()) or {}
1316
+ rows, _ = kb_parse_claims(text)
1317
+ out[(meta.get("topic") or p.stem).strip()] = (p, text, rows)
1318
+ return out
1319
+
1320
+
1321
+ def kb_export_closure(docs, slugs):
1322
+ """(topics, artifacts, added_for_conflicts, errors).
1323
+
1324
+ Closure in two directions, because a partial export produces a target whose
1325
+ own checks fail:
1326
+ * every artifact a selected claim cites travels with it -- a claim whose
1327
+ source cannot be reopened is model knowledge arriving by another route;
1328
+ * every row a CONTESTED row points at travels too, since the symmetry
1329
+ check refuses a set that lost half its members. When such a row lives in
1330
+ an unselected topic, that topic is ADDED and reported, never dropped.
1331
+ """
1332
+ all_topics = kb_collect_topics(docs)
1333
+ errors = []
1334
+ for s in slugs:
1335
+ if s not in all_topics:
1336
+ errors.append("no such topic: %s" % s)
1337
+ if errors:
1338
+ return {}, [], [], errors
1339
+ selected = dict((s, all_topics[s]) for s in slugs)
1340
+ # id -> slug, over the WHOLE graph, so a conflict partner is findable
1341
+ owner_of = {}
1342
+ for slug, (_p, _t, rows) in all_topics.items():
1343
+ for r in rows:
1344
+ if r["id"]:
1345
+ owner_of[r["id"]] = slug
1346
+ added = []
1347
+ pending = list(selected)
1348
+ while pending:
1349
+ slug = pending.pop()
1350
+ for r in selected[slug][2]:
1351
+ m = re.match(r"^(CONTESTED|SUPERSEDED) ([0-9a-f, ]+)$", r["state"].strip())
1352
+ if not m:
1353
+ continue
1354
+ for ref in [x.strip() for x in m.group(2).split(",") if x.strip()]:
1355
+ other = owner_of.get(ref)
1356
+ if other is None:
1357
+ errors.append("claim %s in topic '%s' points at id %s, which "
1358
+ "no topic owns: export would carry a broken set"
1359
+ % (r["id"] or "(no id)", slug, ref))
1360
+ elif other not in selected:
1361
+ selected[other] = all_topics[other]
1362
+ added.append(other)
1363
+ pending.append(other)
1364
+ artifacts = []
1365
+ seen = set()
1366
+ for slug, (_p, _t, rows) in sorted(selected.items()):
1367
+ for r in rows:
1368
+ for rel in kb_claim_sources(r):
1369
+ for cand in (rel, rel + ".meta.md",
1370
+ rel[:-len(Path(rel).suffix)] + ".txt" if Path(rel).suffix else rel):
1371
+ if cand in seen:
1372
+ continue
1373
+ if (docs / cand).is_file():
1374
+ seen.add(cand)
1375
+ artifacts.append(cand)
1376
+ return selected, artifacts, added, errors
1377
+
1378
+
1379
+ def kb_bundle_write(docs, out, selected, artifacts, project):
1380
+ """Write the bundle. Mirrors the docs-root layout; no path is rewritten."""
1381
+ out.mkdir(parents=True, exist_ok=True)
1382
+ (out / "topics").mkdir(exist_ok=True)
1383
+ for slug, (p, text, _rows) in sorted(selected.items()):
1384
+ (out / "topics" / p.name).write_text(text, encoding="utf-8")
1385
+ for rel in artifacts:
1386
+ dst = out / rel
1387
+ dst.parent.mkdir(parents=True, exist_ok=True)
1388
+ dst.write_bytes((docs / rel).read_bytes())
1389
+ lines = ["---", "kb_bundle: 1", "source_project: %s" % project,
1390
+ "topics: [%s]" % ", ".join(sorted(selected)),
1391
+ "artifacts: %d" % len(artifacts), "---",
1392
+ "# KB bundle", "",
1393
+ "Import with `sdlc_check.py import <this directory>`. Additive: it "
1394
+ "never overwrites a node and never deletes anything.", ""]
1395
+ for rel in artifacts:
1396
+ lines.append("- `%s` sha256:%s" % (rel, kb_sha256_bytes(docs / rel)))
1397
+ (out / BUNDLE_MANIFEST).write_text("\n".join(lines) + "\n", encoding="utf-8")
1398
+
1399
+
1400
+ def kb_import_plan(bundle, docs):
1401
+ """(writes, skipped_topics, dedup, errors) -- computed BEFORE anything is
1402
+ written, because an import that half-applies leaves a tree whose checks fail
1403
+ and whose owner cannot tell what landed."""
1404
+ errors, writes, skipped, dedup = [], [], [], []
1405
+ man = bundle / BUNDLE_MANIFEST
1406
+ if not man.is_file():
1407
+ return [], [], [], ["not a kb bundle: no %s in %s" % (BUNDLE_MANIFEST, bundle)]
1408
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(man).splitlines()) or {}
1409
+ if str(meta.get("kb_bundle", "")).strip() != "1":
1410
+ return [], [], [], ["%s carries no 'kb_bundle: 1'" % BUNDLE_MANIFEST]
1411
+
1412
+ known_ids = set()
1413
+ target = kb_collect_topics(docs)
1414
+ for _slug, (_p, _t, rows) in target.items():
1415
+ for r in rows:
1416
+ if r["id"]:
1417
+ known_ids.add(r["id"])
1418
+
1419
+ for p in sorted(bundle.rglob("*")):
1420
+ if not p.is_file() or p.name == BUNDLE_MANIFEST:
1421
+ continue
1422
+ rel = p.relative_to(bundle).as_posix()
1423
+ dst = sdlc_core.confine_under(docs, rel)
1424
+ if dst is None:
1425
+ errors.append("bundle entry %r escapes the docs root — refusing the "
1426
+ "whole import, not just this file" % rel)
1427
+ continue
1428
+ if dst.is_file() and not rel.startswith("topics/"):
1429
+ if kb_sha256_bytes(dst) != kb_sha256_bytes(p):
1430
+ errors.append("%s exists with different bytes (target %s… vs "
1431
+ "bundle %s…): content-addressed names must mean "
1432
+ "equal content" % (rel, kb_sha256_bytes(dst)[:8],
1433
+ kb_sha256_bytes(p)[:8]))
1434
+ continue
1435
+ if rel.startswith("topics/") and dst.is_file():
1436
+ skipped.append(rel)
1437
+ continue
1438
+ writes.append((rel, p, dst))
1439
+
1440
+ for rel, p, _dst in writes:
1441
+ if not rel.startswith("topics/"):
1442
+ continue
1443
+ rows, _ = kb_parse_claims(sdlc_core.read_text(p))
1444
+ for r in rows:
1445
+ if r["id"] and r["id"] in known_ids:
1446
+ dedup.append(r["id"])
1447
+ return writes, skipped, dedup, errors
1448
+
1449
+
1450
+ # ------------------------------------------------------------------ commands
1451
+
1452
+ MEMORY_DIRS = ("vision", "reference", "architecture", "functional", "strategic",
1453
+ "solutions", "research", "strategy", "tactics", "deliverables",
1454
+ "spikes", "topics", "corpus/notes")
1455
+
1456
+
1457
+ def _memory_safe(docs, path):
1458
+ """Reject links (including Windows junctions) and paths outside the docs root."""
1459
+ try:
1460
+ path.resolve().relative_to(docs.resolve())
1461
+ for p in (path, *path.parents):
1462
+ if p == docs.parent:
1463
+ break
1464
+ if p.is_symlink() or (hasattr(p, "is_junction") and p.is_junction()):
1465
+ return False
1466
+ return True
1467
+ except (OSError, ValueError, RuntimeError):
1468
+ return False
1469
+
1470
+
1471
+ def memory_records(docs):
1472
+ """Live metadata, not claims or work states. Never traverse raw corpus or logs."""
1473
+ paths = []
1474
+ readme = docs / "README.md"
1475
+ default = "code"
1476
+ if readme.is_file() and _memory_safe(docs, readme):
1477
+ paths.append(readme)
1478
+ meta = sdlc_core.load_frontmatter(sdlc_core.read_text(readme).splitlines())
1479
+ default = kb_unquote(meta.get("default_domain")) or default
1480
+ for dirname in MEMORY_DIRS:
1481
+ folder = docs / dirname
1482
+ if not folder.is_dir() or not _memory_safe(docs, folder):
1483
+ continue
1484
+ for base, dirs, files in os.walk(folder, followlinks=False):
1485
+ dirs[:] = sorted(d for d in dirs if not d.startswith((".", "harness_"))
1486
+ and _memory_safe(docs, Path(base) / d))
1487
+ for name in files:
1488
+ p = Path(base) / name
1489
+ if (p.suffix.lower() == ".md" and name not in ("INDEX.md", "features_history.md")
1490
+ and _memory_safe(docs, p)):
1491
+ paths.append(p)
1492
+ records = []
1493
+ for p in sorted(set(paths), key=lambda p: p.relative_to(docs).as_posix()):
1494
+ raw = p.read_bytes()
1495
+ text = raw.decode("utf-8", errors="replace")
1496
+ meta = sdlc_core.load_frontmatter(text.splitlines())
1497
+ title = next((line[2:].strip() for line in text.splitlines()
1498
+ if line.startswith("# ")), p.stem)
1499
+ records.append((p.relative_to(docs).as_posix(),
1500
+ kb_unquote(meta.get("domain")) or default,
1501
+ ", ".join(_as_list(meta.get("topics", ""))),
1502
+ kb_unquote(meta.get("description")) or title,
1503
+ hashlib.sha256(raw).hexdigest()))
1504
+ return records
1505
+
1506
+
1507
+ def _memory_table(records):
1508
+ def cell(value):
1509
+ return " ".join(value.split()).replace("|", "&#124;").replace("<", "&lt;")
1510
+ lines = ["| Path | Domain | Topics | Description | SHA256 |", "|---|---|---|---|---|"]
1511
+ lines += ["| " + " | ".join(cell(v) for v in row) + " |" for row in records]
1512
+ return "\n".join(lines) + "\n"
1513
+
1514
+
1515
+ def memory_build_index(docs):
1516
+ return ("# Project memory (generated discovery catalog)\n\n"
1517
+ "Regenerate with the installed validator's index command. Paths are relative "
1518
+ "to the docs root. Read each source for authority, validity and status; "
1519
+ "a catalog entry is not a verified fact.\n\n" + _memory_table(memory_records(docs)))
1520
+
1521
+
1522
+ def memory_index(docs):
1523
+ out = docs / "memory" / "INDEX.md"
1524
+ if not _memory_safe(docs, out):
1525
+ print("[ERROR] unsafe memory/INDEX.md output path; nothing written there")
1526
+ return 1
1527
+ out.parent.mkdir(parents=True, exist_ok=True)
1528
+ with out.open("w", encoding="utf-8", newline="\n") as stream:
1529
+ stream.write(memory_build_index(docs))
1530
+ print("[ok] project memory regenerated: %s" % out)
1531
+ return 0
1532
+
1533
+
1534
+ def memory_validate(docs):
1535
+ out = docs / "memory" / "INDEX.md"
1536
+ if not _memory_safe(docs, out):
1537
+ print("[ERROR] unsafe memory/INDEX.md path")
1538
+ return 1
1539
+ if out.is_file() and sdlc_core.norm_text(sdlc_core.read_text(out)) != \
1540
+ sdlc_core.norm_text(memory_build_index(docs)):
1541
+ print("[ERROR] memory/INDEX.md not aligned: run the validator's index command")
1542
+ return 1
1543
+ return 0
1544
+
1545
+
1546
+ def memory_recall(docs, query):
1547
+ words = query.casefold().split()
1548
+ rows = [r for r in memory_records(docs)
1549
+ if all(w in " ".join(r[:4]).casefold() for w in words)]
1550
+ print("# Project memory: live metadata lookup (read sources before deciding)")
1551
+ print(_memory_table(rows) if rows else "No metadata match; this is not proof of no knowledge.")
1552
+ return 0
1553
+
1554
+
1555
+ def kb_refresh_indexes(docs):
1556
+ """Derived indexes without invoking any domain's main index command."""
1557
+ for dirname, builder in (("topics", kb_build_topic_index), ("corpus", kb_build_corpus_index)):
1558
+ out = docs / dirname / "INDEX.md"
1559
+ if not _memory_safe(docs, out):
1560
+ print("[ERROR] unsafe %s/INDEX.md output path" % dirname)
1561
+ return 1
1562
+ if (docs / dirname).is_dir():
1563
+ out.write_text(builder(docs), encoding="utf-8")
1564
+ print("[ok] %s index regenerated: %s" % (dirname, out))
1565
+ return memory_index(docs)
1566
+
1567
+
1568
+ def kb_validate_surface(docs, full=False):
1569
+ """Additive seam for marketing: preserve its own validator and report."""
1570
+ rc = max(memory_validate(docs), _kb_extra_validate(docs))
1571
+ if full and ((docs / "topics").is_dir() or (docs / "corpus").is_dir()):
1572
+ cycle = kb_time_cycle(docs)
1573
+ rc = max(rc, kb_cmd_graph(docs, cycle), kb_cmd_corpus(docs, cycle))
1574
+ return rc
1575
+
1576
+ INTERCEPTED = {"index", "validate", "check", "graph", "corpus", "claim-id",
1577
+ "anchor", "export", "import", "stale", "recall"}
1578
+
1579
+
1580
+ def _kb_root(args):
1581
+ """Resolve the PROJECT root exactly as the spine's main does, so --docs-dir,
1582
+ the env seam and the two-roots-refuse behaviour hold on intercepted commands.
1583
+ Same call, same order: resolve_docs_dir(args, root) -> (discovered, name).
1584
+ Spine cmd_* take the project root; kb helpers take the docs root under it."""
1585
+ discovered, name = sdlc_core.resolve_docs_dir(args, getattr(args, "root", None))
1586
+ sdlc_core.set_docs_dir(name)
1587
+ root = (Path(args.root).resolve() if args.root
1588
+ else (discovered or sdlc_core.find_project_root()))
1589
+ return root, root / name
1590
+
1591
+
1592
+ def kb_cmd_graph(docs, cycle=None):
1593
+ errors, warnings = kb_graph_check(docs)
1594
+ ce, cw, cn = kb_check_claims(docs)
1595
+ if cycle is None:
1596
+ cycle = kb_time_cycle(docs)
1597
+ tc_cited, _tc_chain, _tc_dang = kb_time_cycle_warns(cycle)
1598
+ total_w = len(warnings) + len(cw) + len(tc_cited)
1599
+ for w in warnings + cw + tc_cited:
1600
+ print("[warn] %s" % w)
1601
+ for n in cn:
1602
+ print("[note] %s" % n)
1603
+ for e in errors + ce:
1604
+ print("[ERROR] %s" % e)
1605
+ total_e = len(errors) + len(ce)
1606
+ if total_e:
1607
+ print("Graph: %d errors, %d warnings." % (total_e, total_w))
1608
+ return 1
1609
+ if not (docs / "topics").is_dir():
1610
+ print("[ok] no topics/ - nothing to check")
1611
+ else:
1612
+ print("[ok] topic graph and claim ledger consistent "
1613
+ "(%d warnings)" % total_w)
1614
+ return 0
1615
+
1616
+
1617
+ def kb_cmd_corpus(docs, cycle=None):
1618
+ errors, warnings = kb_corpus_check(docs)
1619
+ if cycle is None:
1620
+ cycle = kb_time_cycle(docs)
1621
+ _tc_cited, tc_chain, tc_dang = kb_time_cycle_warns(cycle)
1622
+ warnings = warnings + tc_chain + tc_dang
1623
+ for w in warnings:
1624
+ print("[warn] %s" % w)
1625
+ for e in errors:
1626
+ print("[ERROR] %s" % e)
1627
+ if errors:
1628
+ print("Corpus: %d errors, %d warnings." % (len(errors), len(warnings)))
1629
+ return 1
1630
+ if not (docs / "corpus").is_dir():
1631
+ print("[ok] no corpus/ - nothing to check")
1632
+ else:
1633
+ print("[ok] corpus consistent (%d warnings)" % len(warnings))
1634
+ return 0
1635
+
1636
+
1637
+ def kb_cmd_index(root, docs):
1638
+ rc = sdlc_core.cmd_index(root)
1639
+ return max(rc, kb_refresh_indexes(docs)) if rc == 0 else rc
1640
+
1641
+
1642
+ def _kb_extra_validate(docs):
1643
+ """The kb additions to validate: generated-index freshness for the overlay's
1644
+ two indexes. Prints nothing on a tree without them (TS-K7)."""
1645
+ rc = 0
1646
+ checks = (("topics", kb_build_topic_index), ("corpus", kb_build_corpus_index))
1647
+ for dirname, builder in checks:
1648
+ idx = docs / dirname / "INDEX.md"
1649
+ if (docs / dirname).is_dir() and idx.is_file():
1650
+ if sdlc_core.norm_text(sdlc_core.read_text(idx)) != \
1651
+ sdlc_core.norm_text(builder(docs)):
1652
+ print("[ERROR] %s/INDEX.md not aligned: run 'sdlc_check.py index'"
1653
+ % dirname)
1654
+ rc = 1
1655
+ return rc
1656
+
1657
+
1658
+ def kb_cmd_validate(root, docs, strict=False, hybrid=False):
1659
+ rc = sdlc_core.cmd_validate(root, strict=strict, hybrid=hybrid)
1660
+ return max(rc, kb_validate_surface(docs))
1661
+
1662
+
1663
+ def kb_cmd_check(root, docs, strict=False, hybrid=False):
1664
+ # The spine's check owns its banners and summary line: reuse it whole, so a
1665
+ # tree with no kb surface gets byte-identical output. The kb checks run
1666
+ # after, and only when the surface exists.
1667
+ rc = sdlc_core.cmd_check(root, strict=strict, hybrid=hybrid)
1668
+ rc = max(rc, _kb_extra_validate(docs), memory_validate(docs))
1669
+ if (docs / "topics").is_dir() or (docs / "corpus").is_dir():
1670
+ cycle = kb_time_cycle(docs)
1671
+ print("===== graph =====")
1672
+ rc = max(rc, kb_cmd_graph(docs, cycle))
1673
+ print("===== corpus =====")
1674
+ rc = max(rc, kb_cmd_corpus(docs, cycle))
1675
+ return rc
1676
+
1677
+
1678
+ def kb_cmd_stale(root, docs, hybrid=False):
1679
+ """F-044: the consolidation view. The spine's output and exit semantics
1680
+ come first (--hybrid passed through untouched), then the `## claims`
1681
+ section -- superseded-cited docs, expired windows, superseded chains.
1682
+ Nothing is appended on a clean corpus, and the section NEVER moves the
1683
+ spine's rc: the time cycle reports, the spine rules."""
1684
+ rc = sdlc_core.cmd_stale(root, hybrid=hybrid)
1685
+ cycle = kb_time_cycle(docs)
1686
+ cited_w, chain_w, _dang = kb_time_cycle_warns(cycle)
1687
+ lines = list(cited_w)
1688
+ for topic_rel, rid, until, by in cycle["expired"]:
1689
+ lines.append("%s: %s expired %s (%s)"
1690
+ % (topic_rel, rid, until,
1691
+ ("cited by: " + ", ".join(by)) if by else "uncited"))
1692
+ lines += chain_w
1693
+ if lines:
1694
+ print("")
1695
+ print("## claims")
1696
+ for ln in lines:
1697
+ print(ln)
1698
+ return rc
1699
+
1700
+
1701
+ def kb_cmd_claim_id(args):
1702
+ if args.fill:
1703
+ p = Path(args.path)
1704
+ if not p.is_file():
1705
+ print("[ERROR] no such file: %s" % p)
1706
+ return 2
1707
+ before = sdlc_core.read_text(p)
1708
+ after = kb_fill_ids(before)
1709
+ if after != before:
1710
+ p.write_text(after, encoding="utf-8")
1711
+ print("[ok] ids filled in %s" % p)
1712
+ else:
1713
+ print("[ok] nothing to fill in %s" % p)
1714
+ return 0
1715
+ if not args.locator:
1716
+ print("[ERROR] claim-id needs <path> <locator>, or --fill <file>")
1717
+ return 2
1718
+ print(kb_claim_id(args.path, args.locator, kb_qty_key(args.qty or "-")))
1719
+ return 0
1720
+
1721
+
1722
+ def kb_cmd_anchor(args):
1723
+ """Prose citation -> a verified span. The half `claim-id` never had.
1724
+
1725
+ The path may be given as it appears in a claim's `source` cell
1726
+ (`corpus/given/x-ab12cd34.txt`): when it does not resolve from the current
1727
+ directory it is retried under the docs root, so the command works from
1728
+ anywhere in the project instead of only from inside `ai_docs/`. Reported
1729
+ from the field as an asymmetry with `graph`/`corpus`/`check`, which take
1730
+ `--root`; those scan a tree, this one takes a path, and the fix is to make
1731
+ the path resolve rather than to document where to stand."""
1732
+ p = Path(args.path)
1733
+ if not p.is_file():
1734
+ try:
1735
+ _, docs = _kb_root(args)
1736
+ if (docs / args.path).is_file():
1737
+ p = docs / args.path
1738
+ except sdlc_core.AmbiguousDocsRoot:
1739
+ pass
1740
+ if not p.is_file():
1741
+ print("[ERROR] no such file: %s" % args.path)
1742
+ print(" looked from the current directory and under the docs "
1743
+ "root; give the path as the claim's `source` cell carries it.")
1744
+ return 2
1745
+ hits = kb_resolve_anchor(p, args.phrase, ignore_case=args.ignore_case,
1746
+ page=args.page)
1747
+ if not hits:
1748
+ print("[ERROR] phrase not found in the stored bytes of %s" % p)
1749
+ print(" nothing written: a locator you cannot verify is worse "
1750
+ "than no locator.")
1751
+ return 2
1752
+ if len(hits) > 1 and not args.all:
1753
+ print("[ERROR] %d matches -- ambiguous, so nothing is emitted. Narrow "
1754
+ "the phrase, add --page, or pass --all to see them:" % len(hits))
1755
+ for loc, ctx in hits:
1756
+ print(" %s" % loc)
1757
+ return 2
1758
+ for loc, ctx in hits:
1759
+ print(loc)
1760
+ print(" ...%s..." % " ".join(ctx.split()))
1761
+ return 0
1762
+
1763
+
1764
+ def kb_cmd_export(args):
1765
+ root, docs = _kb_root(args)
1766
+ slugs = ([s.strip() for s in args.topics.split(",") if s.strip()]
1767
+ if args.topics else sorted(kb_collect_topics(docs)))
1768
+ if not slugs:
1769
+ print("[ERROR] no topics to export")
1770
+ return 2
1771
+ selected, artifacts, added, errors = kb_export_closure(docs, slugs)
1772
+ for e in errors:
1773
+ print("[ERROR] %s" % e)
1774
+ if errors:
1775
+ return 1
1776
+ out = Path(args.out)
1777
+ kb_bundle_write(docs, out, selected, artifacts, root.name)
1778
+ print("[ok] bundle written: %s" % out)
1779
+ print(" topics: %d, artifacts: %d" % (len(selected), len(artifacts)))
1780
+ if added:
1781
+ # never silent: a set that grew is a fact about the export, and the
1782
+ # alternative -- dropping the partner rows -- ships a broken tree.
1783
+ print(" +%d topic(s) added to keep conflict sets whole: %s"
1784
+ % (len(added), ", ".join(sorted(added))))
1785
+ return 0
1786
+
1787
+
1788
+ def kb_cmd_import(args):
1789
+ _root, docs = _kb_root(args)
1790
+ bundle = Path(args.bundle)
1791
+ if not bundle.is_dir():
1792
+ print("[ERROR] no such bundle directory: %s" % bundle)
1793
+ return 2
1794
+ writes, skipped, dedup, errors = kb_import_plan(bundle, docs)
1795
+ for e in errors:
1796
+ print("[ERROR] %s" % e)
1797
+ if errors:
1798
+ print("[ERROR] nothing was written: an import that half-applies leaves a "
1799
+ "tree whose checks fail and whose owner cannot tell what landed.")
1800
+ return 1
1801
+ if args.dry_run:
1802
+ print("[ok] dry run: %d file(s) would be written" % len(writes))
1803
+ else:
1804
+ for _rel, src, dst in writes:
1805
+ dst.parent.mkdir(parents=True, exist_ok=True)
1806
+ dst.write_bytes(src.read_bytes())
1807
+ print("[ok] imported %d file(s) into %s" % (len(writes), docs))
1808
+ if dedup:
1809
+ print(" %d claim(s) already present, by id — the same artifact cited "
1810
+ "at the same span mints the same id in any project" % len(dedup))
1811
+ for rel in skipped:
1812
+ print("[note] %s already exists: NOT overwritten. Run the placement pass "
1813
+ "(taxonomy.md) and merge by hand — an import never decides that."
1814
+ % rel)
1815
+ print("[note] re-run 'sdlc_check.py check' now: the import is additive, and "
1816
+ "an imported RULING stays IMPORTED until you re-ratify it.")
1817
+ return 0
1818
+
1819
+
1820
+ def kb_cmd_orient(argv):
1821
+ """Forward `orient` to the spine untouched, then append the topic router.
1822
+
1823
+ The recall reflex (SKILL.md section "Topic Recall") needs the graph in
1824
+ sight at session start -- by construction, not exhortation (F-039). The
1825
+ spine's orient is never re-implemented here: the raw argv goes through
1826
+ (the --help pattern below), and orient's flags are never mirrored into an
1827
+ overlay argparse -- the drift class the forward-by-default comment warns
1828
+ about. parse_known_args reads ONLY the two root flags the overlay itself
1829
+ needs to locate topics/, and ignores everything else.
1830
+
1831
+ The append fails open: a broken graph read must never break orient itself.
1832
+ """
1833
+ try:
1834
+ rc = sdlc_core.main(argv)
1835
+ except SystemExit as e:
1836
+ rc = e.code if isinstance(e.code, int) else 1
1837
+ # Pre-bind so a throw INSIDE the router print (after root was validly
1838
+ # bound) does not erase it -- the recency block below must survive a
1839
+ # router failure with the bound root intact (closure R2 finding).
1840
+ root = name = None
1841
+ try:
1842
+ probe = argparse.ArgumentParser(add_help=False)
1843
+ probe.add_argument("--root")
1844
+ probe.add_argument("--docs-dir")
1845
+ known, _ = probe.parse_known_args(argv[1:])
1846
+ try:
1847
+ discovered, name = sdlc_core.resolve_docs_dir(known, known.root)
1848
+ except sdlc_core.AmbiguousDocsRoot:
1849
+ # Mirror the spine's degraded orient: fall back to the default
1850
+ # docs dir rather than losing the router on a half-migrated tree.
1851
+ discovered, name = None, sdlc_core.DEFAULT_DOCS_DIR
1852
+ root = (Path(known.root).resolve() if known.root
1853
+ else (discovered or sdlc_core.find_project_root()))
1854
+ topics = root / name / "topics"
1855
+ if (root / name).is_dir():
1856
+ print("\nProject memory: memory/INDEX.md; use recall QUERY for live metadata. "
1857
+ "Read sources for authority and status.")
1858
+ index = topics / "INDEX.md"
1859
+ if index.is_file():
1860
+ lines = sdlc_core.read_text(index).splitlines()
1861
+ print("\n## Topic router")
1862
+ cap = 30
1863
+ for line in lines[:cap]:
1864
+ print(line)
1865
+ if len(lines) > cap:
1866
+ print("(+%d more lines -- read topics/INDEX.md)" % (len(lines) - cap))
1867
+ elif topics.is_dir():
1868
+ nodes = [p for p in topics.glob("*.md") if p.name != "INDEX.md"]
1869
+ if nodes:
1870
+ print("\n## Topic router")
1871
+ print("index absent (%d node files) -- regenerate: sdlc_check.py index"
1872
+ % len(nodes))
1873
+ # no topics/ at all: the project has no graph; print nothing.
1874
+ except (Exception, SystemExit):
1875
+ # SystemExit included: the probe parser raises it on a dangling flag
1876
+ # value -- the spine already reported that usage error; the append must
1877
+ # never turn it into an escape. root/name keep whatever binding they
1878
+ # reached: pre-binding throws leave them None (recency skips), a throw
1879
+ # inside the router print leaves them valid (recency still runs).
1880
+ pass
1881
+ try:
1882
+ # Notes recency (F-040) -- its OWN try, genuinely independent of the
1883
+ # router: a router throw must not kill this line. It measures NOTES
1884
+ # recency, not full ledger freshness (an ingest feeding claims from
1885
+ # given/ leaves it unmoved). Date source: frontmatter `date:` first;
1886
+ # per-note mtime as fallback -- mtime LIES after clone/worktree (the
1887
+ # skill's own worktree hygiene makes that routine), which is why the
1888
+ # frontmatter date is primary.
1889
+ if root is None:
1890
+ return rc
1891
+ notes_dir = root / name / "corpus" / "notes"
1892
+ if notes_dir.is_dir():
1893
+ import datetime as _dt
1894
+ newest = None
1895
+ newest_mtime = False
1896
+ for p in notes_dir.glob("*.md"):
1897
+ stamp = None
1898
+ from_mtime = False
1899
+ try:
1900
+ # Field datum 7 (on 1.11.0): the old 600-byte head window
1901
+ # silently skipped the WHOLE probe when a real frontmatter
1902
+ # (edge lists, basis lines) pushed the closing fence past
1903
+ # it -- the file was already fully read, so the cap bought
1904
+ # only failure. load_frontmatter is the authoritative
1905
+ # reader (fence-bounded, so a column-0 date: in the body
1906
+ # still cannot misdate the note); kb_unquote covers quoted
1907
+ # dates; the prefix match tolerates a datetime suffix.
1908
+ meta = sdlc_core.load_frontmatter(
1909
+ sdlc_core.read_text(p).splitlines()) or {}
1910
+ m = re.match(r"(\d{4}-\d{2}-\d{2})",
1911
+ kb_unquote(meta.get("date")))
1912
+ if m:
1913
+ stamp = _dt.date.fromisoformat(m.group(1))
1914
+ except Exception:
1915
+ pass
1916
+ if stamp is None:
1917
+ try:
1918
+ stamp = _dt.date.fromtimestamp(p.stat().st_mtime)
1919
+ from_mtime = True
1920
+ except Exception:
1921
+ continue
1922
+ # Strict newer wins; on a tie a DATED stamp beats an mtime one,
1923
+ # so the disclosure below never fires beside a validated date
1924
+ # proving the same day.
1925
+ if (newest is None or stamp > newest
1926
+ or (stamp == newest and newest_mtime
1927
+ and not from_mtime)):
1928
+ newest = stamp
1929
+ newest_mtime = from_mtime
1930
+ if newest is None:
1931
+ print("\nno notes yet (corpus/notes)")
1932
+ else:
1933
+ days = (_dt.date.today() - newest).days
1934
+ # The basis is disclosed when mtime decided the winner: the
1935
+ # field-reported lie was a rewritten note reading "0 days old"
1936
+ # against SKILL.md's date-first promise -- degraded, and mute.
1937
+ basis = "; mtime -- date: not parsed" if newest_mtime else ""
1938
+ print("\nnewest note: %d days old (corpus/notes%s)"
1939
+ % (max(days, 0), basis))
1940
+ except (Exception, SystemExit):
1941
+ # Fail-open: nothing in the recency append may break orient. (The
1942
+ # probe parser lives in the router try above; this guard covers the
1943
+ # date/mtime walk and the print itself.)
1944
+ pass
1945
+ try:
1946
+ # F-044: the time-cycle count line -- once per session at orient,
1947
+ # fail-open, SILENT when clean (noise is failure one). It runs the
1948
+ # shared collector, never a leaner walk: the counts must match what
1949
+ # `stale` lists, or the line under-counts its way to false calm.
1950
+ if root is not None:
1951
+ docs = root / name
1952
+ if (docs / "topics").is_dir() or (docs / "corpus").is_dir():
1953
+ cycle = kb_time_cycle(docs)
1954
+ n_docs = len({c[0] for c in cycle["cited"]})
1955
+ m_exp = len(cycle["expired"])
1956
+ k_chain = len(cycle["chain"])
1957
+ if n_docs or m_exp or k_chain:
1958
+ print("\nkb time: %d docs cite superseded claims; "
1959
+ "%d claims expired; %d derivations on superseded "
1960
+ "sources -- run sdlc_check.py stale"
1961
+ % (n_docs, m_exp, k_chain))
1962
+ except (Exception, SystemExit):
1963
+ pass
1964
+ return rc
1965
+
1966
+
1967
+ def kb_cmd_help():
1968
+ """The spine's usage, then the overlay's own commands.
1969
+
1970
+ Forward-by-default sends everything the overlay does not intercept to the
1971
+ spine -- including `--help`, whose usage line then lists the spine's nine
1972
+ commands and nothing else. A reader concludes the knowledge overlay is not
1973
+ installed; it is, and every command below works. Only `-h/--help` AT argv[0]
1974
+ lands here, so dispatch is otherwise untouched and a future spine command
1975
+ still reaches the spine."""
1976
+ try:
1977
+ sdlc_core.main(["--help"])
1978
+ except SystemExit:
1979
+ pass
1980
+ print("""
1981
+ shared project memory -- also available:
1982
+ recall QUERY live cross-domain document metadata lookup
1983
+ graph topic-graph integrity: placement, edges, cycles
1984
+ corpus corpus integrity: digests, supersession, notes
1985
+ stale spine staleness PLUS `## claims`: superseded-cited
1986
+ docs, expired windows, superseded chains
1987
+ claim-id <path> <locator> compute a claim id (--fill to fill a whole table)
1988
+ anchor <path> <phrase> resolve a phrase to a verified locator span
1989
+ export --out <dir> bundle a subgraph WITH the bytes its claims cite
1990
+ import <dir> merge a bundle in additively (never overwrites)
1991
+ remind one-line per-turn reminder (UserPromptSubmit hook,
1992
+ opt-in -- ENFORCEMENT par.4; constant, zero-read)
1993
+
1994
+ index / validate / check the spine's behaviour PLUS the claim ledger and
1995
+ the topic graph""")
1996
+ return 0
1997
+
1998
+
1999
+ def main(argv=None):
2000
+ argv = list(sys.argv[1:] if argv is None else argv)
2001
+ # `--help` is the one flag the overlay must answer for itself: forwarded, it
2002
+ # renders the spine's usage and hides every command above (field report,
2003
+ # 2026-08-02). Intercepted at argv[0] ONLY.
2004
+ if argv and argv[0] in ("-h", "--help"):
2005
+ return kb_cmd_help()
2006
+ # `orient` is special-cased BEFORE the overlay argparse, like --help: the
2007
+ # spine runs it on the raw argv, the overlay only appends the topic router.
2008
+ if argv and argv[0] == "orient":
2009
+ return kb_cmd_orient(argv)
2010
+ # Forward-by-default: anything not intercepted goes to the spine untouched.
2011
+ # Never a hand-copied command tuple - that is how a spine command gets
2012
+ # silently dropped (mkt_check.py ships that exact defect with `migrate`).
2013
+ if not argv or argv[0] not in INTERCEPTED:
2014
+ return sdlc_core.main(argv)
2015
+ ap = argparse.ArgumentParser(prog="sdlc_check.py (kb overlay)")
2016
+ sub = ap.add_subparsers(dest="cmd", required=True)
2017
+ for name in ("index", "graph", "corpus"):
2018
+ p = sub.add_parser(name)
2019
+ p.add_argument("--root")
2020
+ p.add_argument("--docs-dir")
2021
+ for name in ("validate", "check"):
2022
+ p = sub.add_parser(name)
2023
+ p.add_argument("--root")
2024
+ p.add_argument("--docs-dir")
2025
+ p.add_argument("--strict", action="store_true")
2026
+ p.add_argument("--hybrid", action="store_true")
2027
+ p = sub.add_parser("stale")
2028
+ p.add_argument("--root")
2029
+ p.add_argument("--docs-dir")
2030
+ p.add_argument("--hybrid", action="store_true")
2031
+ p = sub.add_parser("recall")
2032
+ p.add_argument("query")
2033
+ p.add_argument("--root")
2034
+ p.add_argument("--docs-dir")
2035
+ p = sub.add_parser("claim-id")
2036
+ p.add_argument("path")
2037
+ p.add_argument("locator", nargs="?")
2038
+ p.add_argument("--qty")
2039
+ p.add_argument("--fill", action="store_true")
2040
+ p = sub.add_parser("anchor")
2041
+ p.add_argument("path")
2042
+ p.add_argument("phrase")
2043
+ p.add_argument("--root")
2044
+ p.add_argument("--docs-dir")
2045
+ p.add_argument("--page", type=int)
2046
+ p.add_argument("--ignore-case", action="store_true")
2047
+ p.add_argument("--all", action="store_true")
2048
+ p = sub.add_parser("export")
2049
+ p.add_argument("--out", required=True)
2050
+ p.add_argument("--topics")
2051
+ p.add_argument("--root")
2052
+ p.add_argument("--docs-dir")
2053
+ p = sub.add_parser("import")
2054
+ p.add_argument("bundle")
2055
+ p.add_argument("--dry-run", action="store_true")
2056
+ p.add_argument("--root")
2057
+ p.add_argument("--docs-dir")
2058
+ args = ap.parse_args(argv)
2059
+ if args.cmd == "claim-id":
2060
+ return kb_cmd_claim_id(args)
2061
+ if args.cmd == "anchor":
2062
+ return kb_cmd_anchor(args)
2063
+ if args.cmd == "export":
2064
+ return kb_cmd_export(args)
2065
+ if args.cmd == "import":
2066
+ return kb_cmd_import(args)
2067
+ try:
2068
+ root, docs = _kb_root(args)
2069
+ except sdlc_core.AmbiguousDocsRoot as e:
2070
+ # same behaviour as the spine for non-exempt commands
2071
+ print("[ERROR] %s" % e)
2072
+ return 1
2073
+ if args.cmd == "index":
2074
+ return kb_cmd_index(root, docs)
2075
+ if args.cmd == "recall":
2076
+ return memory_recall(docs, args.query)
2077
+ if args.cmd == "stale":
2078
+ return kb_cmd_stale(root, docs, hybrid=args.hybrid)
2079
+ if args.cmd == "graph":
2080
+ return kb_cmd_graph(docs)
2081
+ if args.cmd == "corpus":
2082
+ return kb_cmd_corpus(docs)
2083
+ if args.cmd == "validate":
2084
+ return kb_cmd_validate(root, docs, strict=args.strict, hybrid=args.hybrid)
2085
+ return kb_cmd_check(root, docs, strict=args.strict, hybrid=args.hybrid)
2086
+
2087
+
2088
+ if __name__ == "__main__":
2089
+ sys.exit(main())