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