@antoneeo/kb-agentic-skill 1.17.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.
- package/CHANGELOG.md +21 -0
- package/README.md +176 -160
- package/gemini-extension.json +1 -1
- package/package.json +4 -2
- package/scripts/init.js +6 -17
- package/skills/kb-agentic-skill/ENFORCEMENT.md +3 -3
- package/skills/kb-agentic-skill/SKILL.md +11 -3
- package/skills/kb-agentic-skill/memory.md +88 -0
- package/skills/kb-agentic-skill/routing.md +7 -1
- package/skills/kb-agentic-skill/scripts/knowledge.py +2089 -0
- package/skills/kb-agentic-skill/scripts/sdlc_check.py +62 -2044
- package/skills/kb-agentic-skill/scripts/sdlc_core.py +76 -5
- package/skills/kb-agentic-skill/templates.md +1 -1
|
@@ -1,2044 +1,62 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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())
|