@antoneeo/kb-agentic-skill 1.10.0 → 1.11.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 CHANGED
@@ -2,6 +2,60 @@
2
2
 
3
3
  Every significant change to this skill is recorded here.
4
4
 
5
+ ## [1.11.0] - 2026-08-28
6
+
7
+ F-044 -- the time cycle (second-brain unit 3): claims age, fall, and their fall
8
+ reaches whoever cites or derives from them. Plus two field-reported defects.
9
+
10
+ ### Added
11
+ - **Citation staleness (WARN).** A living document citing a `SUPERSEDED` claim
12
+ id without naming EVERY successor id warns per (document, id) in
13
+ `check`/`graph` -- the vision sentence made mechanical ("no superseded claim
14
+ cited without the supersede visible"). Self-clearing two doctrine-conform
15
+ ways: a full-re-read revision that replaces the id, or an append-only
16
+ correction naming the successors -- history is never rewritten to satisfy a
17
+ check. Generated files (any lens's marker), `corpus/given/` + sidecars, and
18
+ `status: SUPERSEDED` tombstones are exempt; the owning topic self-clears by
19
+ construction.
20
+ - **Expired windows (report-only).** A `valid` window whose half-open `until`
21
+ has passed marks the claim EXPIRED in `stale`'s `## claims` and the orient
22
+ count line -- computed at report time, never written into `state`, never a
23
+ check warning (no successor could clear it: a warn would be an unfixable
24
+ ratchet).
25
+ - **Chain cascade + walkability.** A superseded `given/` artifact now reaches
26
+ every TRANSITIVE derivation through both `derived_from` carriers (note
27
+ frontmatter and `given/*.meta.md` sidecars -- the OCR/extraction class),
28
+ extending the direct-claims warn; and any `derived_from` entry that resolves
29
+ to nothing warns STANDING, supersession or not -- a chain must be walkable
30
+ before anything falls.
31
+ - **`stale` intercepted: the consolidated view.** Spine output, flags and exit
32
+ code preserved (`--hybrid` included), then `## claims` -- superseded-cited
33
+ docs, expired windows, superseded chains -- appended only when nonempty and
34
+ never moving the rc. One staleness door per lens.
35
+ - **Orient count line.** `kb time: N docs cite superseded claims; M claims
36
+ expired; K derivations on superseded sources -- run sdlc_check.py stale` --
37
+ once per session, only when dirty, always fail-open. Event-driven by design:
38
+ reconciliation ends a supersession by running `stale`; nothing polls per
39
+ turn.
40
+
41
+ Two field-reported defects (2026-08-28), both in the frontmatter-value substrate.
42
+
43
+ ### Fixed
44
+ - **YAML-quoted values are unquoted at the point of use** (`kb_unquote`).
45
+ `original_path:` values quoted because the path contains spaces produced
46
+ false "does not resolve" warnings -- 102 on one real corpus; the pointers
47
+ resolved all along. Worse, a quoted `supersedes:` silently MISSED the
48
+ supersession (no warning at all, and the claims-resting-on-superseded check
49
+ never fired). Both readers, plus the corpus-INDEX display, now strip one
50
+ pair of wrapping quotes; a genuinely dangling quoted path still warns.
51
+ - **The orient recency line discloses when mtime decided it.** The line is
52
+ date-first as documented, but any note whose `date:` failed to parse fell
53
+ back to file mtime IN SILENCE -- and mtime lies after a rewrite or checkout,
54
+ so a note dated days ago could read "0 days old" with nothing marking the
55
+ degraded basis. Quoted dates now parse, a dated stamp beats an mtime stamp
56
+ on a same-day tie, and when the winning stamp still came from mtime the line
57
+ says so: `(corpus/notes; mtime -- date: not parsed)`.
58
+
5
59
  ## [1.10.0] - 2026-08-28
6
60
 
7
61
  F-043 -- the revision doctrine: documents read as current state.
package/README.md CHANGED
@@ -37,7 +37,7 @@ Deliberately absent: any per-node coverage or completion state. `gaps:` says wha
37
37
  - **Independent review, twice**: the design before it is implemented, the result before it is declared done — fresh-context subagent > one-shot run > a declared self-pass, 3 rounds max, one log line each, and a PASS is invalid on "found nothing".
38
38
  - **Question discipline**: a question is legal only when the agent searched first, names the search with its result, and names the decision it unblocks; otherwise it proceeds on a declared assumption, batched.
39
39
  - **Operative guides + agent-global KB**: distil user-provided indications into source-faithful `GUIDE_*.md` (`source_kind: document`) — verbatim snapshot plus hash, so drift is detected mechanically.
40
- - **Mechanical checks**: `check`, `validate`, `index`, `graph`, `corpus`, `claim-id`, `anchor`, `export`, `import`, plus the spine's `stale`/`mark`/`gate`/`plan`/`orient`/`migrate`. The graph and corpus checks verify spans against the stored extraction, recompute every id, refuse cycles and unreachable nodes, and enforce `CONTESTED` symmetry.
40
+ - **Mechanical checks**: `check`, `validate`, `index`, `graph`, `corpus`, `claim-id`, `anchor`, `export`, `import`, `stale` (spine staleness plus `## claims` -- superseded-cited documents, expired `valid` windows, superseded derivation chains), plus the spine's `mark`/`gate`/`plan`/`orient`/`migrate`. The graph and corpus checks verify spans against the stored extraction, recompute every id, refuse cycles and unreachable nodes, and enforce `CONTESTED` symmetry.
41
41
  - **Installed support files**: Claude, Codex, Gemini and Google Antigravity receive the full skill folder — `SKILL.md`, `templates.md`, `taxonomy.md`, `distillation.md`, `reconciliation.md`, `guides.md`, `vision.md`, `elicitation.md`, `review.md`, `dispatch.md`, `routing.md`, `ENFORCEMENT.md`, and the validator's two files, `scripts/sdlc_check.py` + `scripts/sdlc_core.py` (the core is the family's shared spine — copy both, or neither).
42
42
 
43
43
  ## Installation
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kb-agentic-skill",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Knowledge-Base & Document-First protocol with risk triage, Vision governance, signal distillation and optional devPNT integration.",
5
5
  "author": "Antonio Pinto (https://github.com/Antoneeo)"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antoneeo/kb-agentic-skill",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Knowledge-Base & Document-First protocol for Claude Code, Gemini CLI, Google Antigravity and Codex with risk triage, Vision governance, signal distillation and optional devPNT integration.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -10,7 +10,45 @@ The agent runs a single gate at closure (Phase 5):
10
10
  python "<skill_dir>/scripts/sdlc_check.py" check
11
11
  ```
12
12
 
13
- (`check` = validate + stale in one command.) Exit code ≠ 0 ⇒ the feature is not declared closed. This is the minimum level the skill expects.
13
+ (`check` = validate + stale in one command -- the SPINE's stale: the kb
14
+ `## claims` section prints only under the overlay's own `stale` command,
15
+ while `check` carries the same findings as warn lines.) Exit code ≠ 0
16
+ ⇒ the feature is not declared closed. This is the minimum level the
17
+ skill expects.
18
+
19
+ ### The time cycle (F-044): superseded citations, expired windows, chains
20
+
21
+ Three read-only checks share one collector and never touch exit-code
22
+ semantics beyond ordinary warnings:
23
+
24
+ - **Citation staleness (WARN, in `check`/`graph`).** A living document
25
+ citing a `SUPERSEDED` claim id without naming EVERY successor id warns
26
+ per (document, id). Self-clearing: a full-re-read revision that
27
+ replaces the id, or an append-only correction naming the successors,
28
+ both end the warn -- history is never rewritten to satisfy a check.
29
+ Exempt: generated files (header marker, any lens), `corpus/given/` and
30
+ its sidecars, `status: SUPERSEDED` tombstones. The owning topic file
31
+ self-clears by construction (its state cell carries the successors).
32
+ WARN and not ERROR by owner ruling: the vision demands the supersede
33
+ be VISIBLE where cited, not that closures block on unrelated debt.
34
+ - **Expired windows (report-only).** A `valid` window whose half-open
35
+ `until` has passed marks the claim EXPIRED in `stale`'s `## claims`
36
+ and the orient count -- never a state write, never a check warning:
37
+ expiry is honest self-declaration with no successor whose naming
38
+ could clear a warn, so a warn would be an unfixable ratchet.
39
+ - **Chain cascade + walkability (WARN, in `check`/`corpus`).** A
40
+ superseded `given/` artifact reaches every transitive derivation
41
+ through both `derived_from` carriers (note frontmatter and sidecars);
42
+ a `derived_from` entry that resolves to nothing warns STANDING --
43
+ supersession or not -- because a chain must be walkable before
44
+ anything falls.
45
+
46
+ The consolidated view is `sdlc_check.py stale` (the overlay intercepts
47
+ the spine command, preserves its output, flags and exit code --
48
+ `--hybrid` included -- and appends `## claims` only when nonempty). The
49
+ orient append prints one count line at session start, only when dirty
50
+ and always fail-open. Event-driven by design: reconciliation §2 runs
51
+ `stale` after recording a supersession; nothing polls per turn.
14
52
 
15
53
  ## 2. Check in CI (recommended for teams)
16
54
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: kb-agentic
3
- version: 1.10.0
3
+ version: 1.11.0
4
4
  description: Knowledge-Base & Document-First protocol with risk-proportional triage, Vision as a guide, Signal Distillation, a complete Standalone mode and optional symbiosis with devPNT. Use for user documentation, knowledge extraction, SOPs, research notes, decision logs, knowledge management — and for answering project questions from the claim ledger and topic graph.
5
5
  author: Antonio Pinto (https://github.com/Antoneeo)
6
6
  copyright: (c) 2026 Antonio Pinto
@@ -23,7 +23,7 @@ Support files in the skill directory:
23
23
  - `review.md`: the review discipline — when a review is due, how to request one, how to receive findings, how to review.
24
24
  - `dispatch.md`: opt-in subagent execution of an approved plan.
25
25
  - `routing.md`: which lens owns this unit of work. Read ONLY when a sibling lens skill is installed alongside this one; a single-lens install never reads it.
26
- - `scripts/sdlc_check.py` + `scripts/sdlc_core.py`: the mechanical validator for the docs root (`check`, `validate`, `index`, `stale`, `mark`, `gate`, `plan`, `orient`, `migrate`, and the knowledge overlay: `graph`, `corpus`, `claim-id`, `anchor`, `export`, `import`, plus `remind` — the opt-in per-turn one-line reminder, ENFORCEMENT §4). Two files: the core is the family's shared spine; the entry point IS the knowledge overlay — the claim ledger and topic-graph checks live inside it, so the core alone runs none of them. Copy both, or neither.
26
+ - `scripts/sdlc_check.py` + `scripts/sdlc_core.py`: the mechanical validator for the docs root (`check`, `validate`, `index`, `mark`, `gate`, `plan`, `orient`, `migrate`, and the knowledge overlay: `graph`, `corpus`, `claim-id`, `anchor`, `export`, `import`, `stale` (spine staleness plus `## claims` -- the time cycle's consolidated view), plus `remind` — the opt-in per-turn one-line reminder, ENFORCEMENT §4). Two files: the core is the family's shared spine; the entry point IS the knowledge overlay — the claim ledger and topic-graph checks live inside it, so the core alone runs none of them. Copy both, or neither.
27
27
  - `ENFORCEMENT.md`: optional setup for CI and hooks.
28
28
 
29
29
  Read these files only when needed. `SKILL.md` is the operating contract; the support files are progressive resources.
@@ -132,6 +132,10 @@ Use this mode when `devpnt_*` tools are available and point at the current proje
132
132
 
133
133
  ### 5. Closure & Indexing
134
134
  - Run verification checks (`sdlc_check.py check --root <project_root>`).
135
+ - If the session recorded any supersession, run `sdlc_check.py stale`: its
136
+ `## claims` section is the revision worklist (superseded-cited documents,
137
+ expired windows, superseded derivation chains), worked per `## Revision`
138
+ -- one document, one full re-read.
135
139
  - If the work was governed by user-provided indications and is reusable, **PROPOSE distilling a guide** (proactive trigger, `guides.md` §1) — a proposal for the user, never a silent write, never from model knowledge.
136
140
  - Update `audit/handoff.md`.
137
141
  - Regenerate manifests: `python <skill_dir>/scripts/sdlc_check.py index --root <project_root>`.
@@ -265,3 +269,6 @@ reads as CURRENT state — is never re-verified. The gesture:
265
269
  - `scripts/sdlc_check.py index`: updates `ai_docs/INDEX.md` and `ai_docs/reference/INDEX.md`.
266
270
  - `scripts/sdlc_check.py validate`: validates YAML frontmatter (`status`, `description`).
267
271
  - `scripts/sdlc_check.py check`: checks dirty closures and missing indexes.
272
+ - `scripts/sdlc_check.py stale`: spine staleness plus `## claims` --
273
+ superseded-cited documents, expired `valid` windows, superseded
274
+ derivation chains (the time cycle's consolidated view).
@@ -41,6 +41,15 @@ dangling ids are errors, duplicates are errors.
41
41
  is now the assertion, and no row lingers contested against rows that no longer
42
42
  contest it.
43
43
 
44
+ 3. **The cascade runs.** Recording a supersession ends by running
45
+ `sdlc_check.py stale`: its `## claims` section lists every living
46
+ document still citing the fallen id without naming its successor(s)
47
+ and every derivation resting on a superseded source -- the revision
48
+ worklist, worked per the kb `## Revision` doctrine, one document at a
49
+ time. The citation warn also stands in `check` until each document is
50
+ revised (or an append-only correction naming the successors lands),
51
+ so the worklist cannot be silently forgotten.
52
+
44
53
  **No basis, no ruling.** A preference is not a fact; the validator refuses a ruling note
45
54
  without `basis:` exactly as it refuses a DERIVED note without `derived_from:`. If the
46
55
  practitioner knows nothing new, the set stays `CONTESTED` — a legitimate, permanent,
@@ -76,19 +85,19 @@ Whole documents keep the family lifecycle: a superseding note or guide marks the
76
85
  `status: SUPERSEDED`; `supersedes:` in the new one's frontmatter links them. Claims and
77
86
  documents move independently — superseding a document does not silently resolve the
78
87
  claims extracted from it; the `corpus` check reports claims resting on superseded
79
- originals for re-verification.
80
-
81
- ## Rulings that came from another project
82
-
83
- An `IMPORTED` row is a ruling made by the owner of a different project
84
- (`portability.md`). Knowledge crosses a project boundary; authority does not.
85
-
86
- - It keeps its text, its span and its original `basis:` verbatim — the knowledge is not
87
- lost, and relabelling it `DERIVED` would make the row lie about its origin.
88
- - Its note must carry `imported_from:`. Unnamed, the class says nothing and the row is a
89
- `RULING` with the label filed off; the validator refuses it.
90
- - **It cannot settle anything here.** `SUPERSEDED <id>` pointing at an `IMPORTED` row is
91
- an error: nobody in this project granted that decision its authority.
92
-
93
- Re-ratification is one act, and it is you deciding: read it, write your own note with
94
- your own `basis:`, set `prov` to `RULING`.
88
+ originals for re-verification.
89
+
90
+ ## Rulings that came from another project
91
+
92
+ An `IMPORTED` row is a ruling made by the owner of a different project
93
+ (`portability.md`). Knowledge crosses a project boundary; authority does not.
94
+
95
+ - It keeps its text, its span and its original `basis:` verbatim — the knowledge is not
96
+ lost, and relabelling it `DERIVED` would make the row lie about its origin.
97
+ - Its note must carry `imported_from:`. Unnamed, the class says nothing and the row is a
98
+ `RULING` with the label filed off; the validator refuses it.
99
+ - **It cannot settle anything here.** `SUPERSEDED <id>` pointing at an `IMPORTED` row is
100
+ an error: nobody in this project granted that decision its authority.
101
+
102
+ Re-ratification is one act, and it is you deciding: read it, write your own note with
103
+ your own `basis:`, set `prov` to `RULING`.
@@ -13,11 +13,14 @@ From the overlay:
13
13
  claim-id --fill <file> fill missing ids in a claim table
14
14
  graph [--root R] topic-graph integrity checks
15
15
  corpus [--root R] corpus checks (supersession, digests)
16
+ stale [--root R] [--hybrid] spine staleness PLUS `## claims`:
17
+ superseded-cited docs, expired
18
+ windows, superseded chains (F-044)
16
19
  index / validate / check spine behaviour PLUS the kb surface
17
20
  (byte-identical output on a tree
18
21
  with no topics/ or corpus/)
19
22
 
20
- Every other spine subcommand (stale, mark, gate, orient, plan, migrate, and any
23
+ Every other spine subcommand (mark, gate, orient, plan, migrate, and any
21
24
  future one) is FORWARDED to the spine untouched: dispatch is "not intercepted ->
22
25
  forward", never a hand-copied command tuple, so a new spine command cannot be
23
26
  silently dropped here.
@@ -171,6 +174,18 @@ def kb_claim_id(source_path, locator, qty_key=""):
171
174
  return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
172
175
 
173
176
 
177
+ def kb_unquote(value):
178
+ """Strip ONE pair of wrapping YAML quotes (then re-strip whitespace).
179
+ load_frontmatter keeps values verbatim by design; paths with spaces force
180
+ quoting, so value-consuming readers unquote at the point of use. Field
181
+ defect 2026-08-28: 102 false dangling original_path warns on one real
182
+ corpus, and a quoted supersedes: would silently MISS the supersession."""
183
+ v = (value or "").strip()
184
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
185
+ return v[1:-1].strip()
186
+ return v
187
+
188
+
174
189
  def kb_parse_scope(text):
175
190
  """'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
176
191
  ('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
@@ -891,7 +906,7 @@ def kb_build_corpus_index(root):
891
906
  for meta_p in sorted(given.glob("*.meta.md")):
892
907
  meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
893
908
  orig = meta_p.name[:-len(".meta.md")]
894
- sup = (meta.get("supersedes") or "").strip()
909
+ sup = kb_unquote(meta.get("supersedes"))
895
910
  lines.append("- `%s` — %s%s — %s" % (
896
911
  orig, (meta.get("date") or "undated"),
897
912
  (" — supersedes `%s`" % sup) if sup else "",
@@ -1050,7 +1065,7 @@ def kb_corpus_check(root):
1050
1065
  # reason does not extend to the PATH, which costs one exists(). A
1051
1066
  # corpus whose premise is "every provenance is a real file" cannot
1052
1067
  # let 16 sidecars go dangling behind a green run.
1053
- op = (meta.get("original_path") or "").strip()
1068
+ op = kb_unquote(meta.get("original_path"))
1054
1069
  if op:
1055
1070
  cands = _kb_original_candidates(op, root)
1056
1071
  tried = [str(c) for c in cands]
@@ -1063,7 +1078,7 @@ def kb_corpus_check(root):
1063
1078
  "artifacts and sidecars, never the originals, so after "
1064
1079
  "an import this dangles legitimately"
1065
1080
  % (rel, op, ", ".join(tried)))
1066
- sup = (meta.get("supersedes") or "").strip()
1081
+ sup = kb_unquote(meta.get("supersedes"))
1067
1082
  if sup:
1068
1083
  superseded.add(sup)
1069
1084
  if not (given / sup).is_file():
@@ -1100,6 +1115,208 @@ def kb_sha256_bytes(path):
1100
1115
  return h.hexdigest()
1101
1116
 
1102
1117
 
1118
+ # -------------------------------------------------------- time cycle (F-044)
1119
+ # The three lists of the kb time cycle, computed ONCE per invocation and fed to
1120
+ # every surface (check/graph/corpus warn streams, `stale`, orient) -- never a
1121
+ # leaner re-implementation per surface, which is how under-counting is born.
1122
+
1123
+ KB_GENERATED_RE = re.compile(r"GENERATED by \S+ index - do not edit by hand")
1124
+ KB_HEX12_RE = re.compile(r"^[0-9a-f]{12}$")
1125
+
1126
+
1127
+ def _kb_hex_bounded(text, cid):
1128
+ """True when cid appears NOT embedded in a longer hex run: a git hash
1129
+ sharing a substring must never count as a citation (either side, either
1130
+ case). cid is pre-validated 12-hex, so it is regex-safe by construction."""
1131
+ return re.search(r"(?<![0-9a-fA-F])%s(?![0-9a-fA-F])" % cid,
1132
+ text) is not None
1133
+
1134
+
1135
+ def _kb_df_entries(value):
1136
+ """A derived_from cell -> its entries: a single value or the inline list
1137
+ `[a, b]`, each kb_unquote'd (quoted paths with spaces are legal YAML)."""
1138
+ v = (value or "").strip()
1139
+ if not v:
1140
+ return []
1141
+ if v.startswith("[") and v.endswith("]"):
1142
+ return [kb_unquote(e) for e in v[1:-1].split(",") if kb_unquote(e)]
1143
+ return [kb_unquote(v)]
1144
+
1145
+
1146
+ def kb_time_cycle(docs):
1147
+ """-> {"cited": [(doc_rel, old_id, missing_ids, succ_ids)],
1148
+ "expired": [(topic_rel, id, until, cited_by)],
1149
+ "chain": [(node_rel, ground_rel, via)],
1150
+ "dangling": [(owner_rel, raw_entry)]} -- each list sorted.
1151
+
1152
+ Read-only, tolerant per file: a malformed table or frontmatter drops that
1153
+ file's contribution (the syntax finding belongs to the claims/corpus
1154
+ checks). cited: a LIVING document containing a SUPERSEDED id without every
1155
+ successor id visible in the same document -- the self-clearing condition,
1156
+ so an append-only Diary correction naming the successors is a legal fix.
1157
+ Living excludes corpus/given/ (+ sidecars: remedy-free), generated files
1158
+ (header marker, ANY lens's entry point), and SUPERSEDED tombstones; the
1159
+ OWNING topic self-clears by construction (its state cell carries every
1160
+ successor id). expired: `valid` windows whose half-open `until` has
1161
+ passed -- computed here, NEVER written back (state writes belong to
1162
+ reconciliation). chain: transitive reverse derived_from closure from
1163
+ superseded given/ artifacts, edges from BOTH carriers (note frontmatter
1164
+ and given/*.meta.md sidecars). dangling: any derived_from entry that
1165
+ resolves to nothing -- standing, supersession or not: a chain must be
1166
+ walkable BEFORE anything falls."""
1167
+ import datetime as _dt
1168
+ import posixpath
1169
+ cited, expired, chain, dangling = [], [], [], []
1170
+ superseded = {}
1171
+ windows = []
1172
+ topics = docs / "topics"
1173
+ if topics.is_dir():
1174
+ for p in sorted(topics.glob("*.md")):
1175
+ if p.name == "INDEX.md":
1176
+ continue
1177
+ try:
1178
+ rows, _ = kb_parse_claims(sdlc_core.read_text(p))
1179
+ except Exception:
1180
+ continue
1181
+ rel = "topics/" + p.name
1182
+ for row in rows:
1183
+ rid = (row.get("id") or "").strip()
1184
+ if not KB_HEX12_RE.match(rid):
1185
+ continue
1186
+ m = re.match(r"^SUPERSEDED ([0-9a-f, ]+)$",
1187
+ (row.get("state") or "").strip())
1188
+ if m:
1189
+ succs = tuple(x.strip() for x in m.group(1).split(",")
1190
+ if KB_HEX12_RE.match(x.strip()))
1191
+ if succs:
1192
+ superseded[rid] = succs
1193
+ try:
1194
+ scope = kb_parse_scope(row.get("valid") or "-")
1195
+ except Exception:
1196
+ continue
1197
+ if scope[0] == "window" and scope[2]:
1198
+ windows.append((rel, rid, scope[2]))
1199
+ living = []
1200
+ for p in sorted(docs.rglob("*.md")):
1201
+ rel = p.relative_to(docs).as_posix()
1202
+ if rel.startswith("corpus/given/"):
1203
+ continue
1204
+ try:
1205
+ text = sdlc_core.read_text(p)
1206
+ except Exception:
1207
+ continue
1208
+ if KB_GENERATED_RE.search(text[:512]):
1209
+ continue
1210
+ meta = sdlc_core.load_frontmatter(text.splitlines()) or {}
1211
+ if (meta.get("status") or "").strip() == "SUPERSEDED":
1212
+ continue
1213
+ living.append((rel, text))
1214
+ for rel, text in living:
1215
+ for old in sorted(superseded):
1216
+ if _kb_hex_bounded(text, old):
1217
+ succs = superseded[old]
1218
+ missing = tuple(x for x in succs
1219
+ if not _kb_hex_bounded(text, x))
1220
+ if missing:
1221
+ cited.append((rel, old, missing, succs))
1222
+ today = _dt.date.today().isoformat()
1223
+ for topic_rel, rid, until in sorted(windows):
1224
+ if until <= today:
1225
+ by = tuple(rel for rel, text in living
1226
+ if rel != topic_rel and _kb_hex_bounded(text, rid))
1227
+ expired.append((topic_rel, rid, until, by))
1228
+ rev = {}
1229
+ node_meta = {}
1230
+ sup_arts = set()
1231
+ notes_dir = docs / "corpus" / "notes"
1232
+ given_dir = docs / "corpus" / "given"
1233
+
1234
+ def _resolve(entry):
1235
+ # Normalize BEFORE resolving: `./x`, `a//b`, `a/../b` resolve on the
1236
+ # filesystem but would never match the canonical node keys, so the
1237
+ # walk would silently stop with neither a chain entry nor a dangling
1238
+ # warn (closure-review WARN-1). Backslashed spellings stay loud: no
1239
+ # `/` means the bare-name ladder, which dangles them visibly.
1240
+ entry = posixpath.normpath(entry)
1241
+ if "/" in entry:
1242
+ return entry if (docs / entry).is_file() else None
1243
+ for base in ("corpus/notes/", "corpus/given/"):
1244
+ if (docs / (base + entry)).is_file():
1245
+ return base + entry
1246
+ return None
1247
+
1248
+ def _edges_of(owner_rel, meta):
1249
+ for e in _kb_df_entries(meta.get("derived_from")):
1250
+ target = _resolve(e)
1251
+ if target is None:
1252
+ dangling.append((owner_rel, e))
1253
+ else:
1254
+ rev.setdefault(target, set()).add(owner_rel)
1255
+
1256
+ if notes_dir.is_dir():
1257
+ for p in sorted(notes_dir.glob("*.md")):
1258
+ rel = "corpus/notes/" + p.name
1259
+ try:
1260
+ meta = sdlc_core.load_frontmatter(
1261
+ sdlc_core.read_text(p).splitlines()) or {}
1262
+ except Exception:
1263
+ continue
1264
+ node_meta[rel] = meta
1265
+ _edges_of(rel, meta)
1266
+ if given_dir.is_dir():
1267
+ for mp in sorted(given_dir.glob("*.meta.md")):
1268
+ orig_rel = "corpus/given/" + mp.name[:-len(".meta.md")]
1269
+ try:
1270
+ meta = sdlc_core.load_frontmatter(
1271
+ sdlc_core.read_text(mp).splitlines()) or {}
1272
+ except Exception:
1273
+ continue
1274
+ node_meta[orig_rel] = meta
1275
+ _edges_of(orig_rel, meta)
1276
+ sup = kb_unquote(meta.get("supersedes"))
1277
+ if sup:
1278
+ sup_arts.add("corpus/given/" + sup)
1279
+ for ground in sorted(sup_arts):
1280
+ seen = {ground}
1281
+ frontier = [(ground, ())]
1282
+ while frontier:
1283
+ node, via = frontier.pop()
1284
+ for owner in sorted(rev.get(node, ())):
1285
+ if owner in seen:
1286
+ continue
1287
+ seen.add(owner)
1288
+ owner_via = via + (node,)
1289
+ tomb = (node_meta.get(owner, {}).get("status")
1290
+ or "").strip() == "SUPERSEDED"
1291
+ if owner not in sup_arts and not tomb:
1292
+ chain.append((owner, ground, owner_via))
1293
+ frontier.append((owner, owner_via))
1294
+ cited.sort()
1295
+ expired.sort()
1296
+ chain.sort()
1297
+ dangling.sort()
1298
+ return {"cited": cited, "expired": expired,
1299
+ "chain": chain, "dangling": dangling}
1300
+
1301
+
1302
+ def kb_time_cycle_warns(cycle):
1303
+ """-> (cited_lines, chain_lines, dangling_lines). ASCII only."""
1304
+ cited = ["%s: cites %s, superseded by %s -- successor(s) %s not named; "
1305
+ "revise per the Revision doctrine"
1306
+ % (rel, old, ", ".join(succs), ", ".join(missing))
1307
+ for rel, old, missing, succs in cycle["cited"]]
1308
+ chain = []
1309
+ for node, ground, via in cycle["chain"]:
1310
+ tail = ("supersede the note" if node.startswith("corpus/notes/")
1311
+ else "supersede the derivation")
1312
+ chain.append("%s: derives (via %s) from %s, which a newer version "
1313
+ "supersedes -- re-derive from the successor or %s"
1314
+ % (node, " -> ".join(via), ground, tail))
1315
+ dang = ["%s: derived_from %s resolves to nothing -- the chain is not "
1316
+ "walkable" % (o, e) for o, e in cycle["dangling"]]
1317
+ return cited, chain, dang
1318
+
1319
+
1103
1320
  # ------------------------------------------------------- portability (F-030)
1104
1321
  # Export a subgraph WITH the bytes its claims cite; import it additively.
1105
1322
  #
@@ -1272,7 +1489,7 @@ def kb_import_plan(bundle, docs):
1272
1489
  # ------------------------------------------------------------------ commands
1273
1490
 
1274
1491
  INTERCEPTED = {"index", "validate", "check", "graph", "corpus", "claim-id",
1275
- "anchor", "export", "import"}
1492
+ "anchor", "export", "import", "stale"}
1276
1493
 
1277
1494
 
1278
1495
  def _kb_root(args):
@@ -1287,10 +1504,14 @@ def _kb_root(args):
1287
1504
  return root, root / name
1288
1505
 
1289
1506
 
1290
- def kb_cmd_graph(docs):
1507
+ def kb_cmd_graph(docs, cycle=None):
1291
1508
  errors, warnings = kb_graph_check(docs)
1292
1509
  ce, cw, cn = kb_check_claims(docs)
1293
- for w in warnings + cw:
1510
+ if cycle is None:
1511
+ cycle = kb_time_cycle(docs)
1512
+ tc_cited, _tc_chain, _tc_dang = kb_time_cycle_warns(cycle)
1513
+ total_w = len(warnings) + len(cw) + len(tc_cited)
1514
+ for w in warnings + cw + tc_cited:
1294
1515
  print("[warn] %s" % w)
1295
1516
  for n in cn:
1296
1517
  print("[note] %s" % n)
@@ -1298,18 +1519,22 @@ def kb_cmd_graph(docs):
1298
1519
  print("[ERROR] %s" % e)
1299
1520
  total_e = len(errors) + len(ce)
1300
1521
  if total_e:
1301
- print("Graph: %d errors, %d warnings." % (total_e, len(warnings) + len(cw)))
1522
+ print("Graph: %d errors, %d warnings." % (total_e, total_w))
1302
1523
  return 1
1303
1524
  if not (docs / "topics").is_dir():
1304
1525
  print("[ok] no topics/ - nothing to check")
1305
1526
  else:
1306
1527
  print("[ok] topic graph and claim ledger consistent "
1307
- "(%d warnings)" % (len(warnings) + len(cw)))
1528
+ "(%d warnings)" % total_w)
1308
1529
  return 0
1309
1530
 
1310
1531
 
1311
- def kb_cmd_corpus(docs):
1532
+ def kb_cmd_corpus(docs, cycle=None):
1312
1533
  errors, warnings = kb_corpus_check(docs)
1534
+ if cycle is None:
1535
+ cycle = kb_time_cycle(docs)
1536
+ _tc_cited, tc_chain, tc_dang = kb_time_cycle_warns(cycle)
1537
+ warnings = warnings + tc_chain + tc_dang
1313
1538
  for w in warnings:
1314
1539
  print("[warn] %s" % w)
1315
1540
  for e in errors:
@@ -1365,10 +1590,34 @@ def kb_cmd_check(root, docs, strict=False, hybrid=False):
1365
1590
  rc = sdlc_core.cmd_check(root, strict=strict, hybrid=hybrid)
1366
1591
  rc = max(rc, _kb_extra_validate(docs))
1367
1592
  if (docs / "topics").is_dir() or (docs / "corpus").is_dir():
1593
+ cycle = kb_time_cycle(docs)
1368
1594
  print("===== graph =====")
1369
- rc = max(rc, kb_cmd_graph(docs))
1595
+ rc = max(rc, kb_cmd_graph(docs, cycle))
1370
1596
  print("===== corpus =====")
1371
- rc = max(rc, kb_cmd_corpus(docs))
1597
+ rc = max(rc, kb_cmd_corpus(docs, cycle))
1598
+ return rc
1599
+
1600
+
1601
+ def kb_cmd_stale(root, docs, hybrid=False):
1602
+ """F-044: the consolidation view. The spine's output and exit semantics
1603
+ come first (--hybrid passed through untouched), then the `## claims`
1604
+ section -- superseded-cited docs, expired windows, superseded chains.
1605
+ Nothing is appended on a clean corpus, and the section NEVER moves the
1606
+ spine's rc: the time cycle reports, the spine rules."""
1607
+ rc = sdlc_core.cmd_stale(root, hybrid=hybrid)
1608
+ cycle = kb_time_cycle(docs)
1609
+ cited_w, chain_w, _dang = kb_time_cycle_warns(cycle)
1610
+ lines = list(cited_w)
1611
+ for topic_rel, rid, until, by in cycle["expired"]:
1612
+ lines.append("%s: %s expired %s (%s)"
1613
+ % (topic_rel, rid, until,
1614
+ ("cited by: " + ", ".join(by)) if by else "uncited"))
1615
+ lines += chain_w
1616
+ if lines:
1617
+ print("")
1618
+ print("## claims")
1619
+ for ln in lines:
1620
+ print(ln)
1372
1621
  return rc
1373
1622
 
1374
1623
 
@@ -1563,17 +1812,22 @@ def kb_cmd_orient(argv):
1563
1812
  if notes_dir.is_dir():
1564
1813
  import datetime as _dt
1565
1814
  newest = None
1815
+ newest_mtime = False
1566
1816
  for p in notes_dir.glob("*.md"):
1567
1817
  stamp = None
1818
+ from_mtime = False
1568
1819
  try:
1569
1820
  head = sdlc_core.read_text(p)[:600]
1570
1821
  # `date:` counts only INSIDE the frontmatter block: a
1571
1822
  # column-0 date: in a note's body must not misdate it.
1823
+ # The optional quote: YAML-quoted dates are legal and the
1824
+ # silent mtime fallback is exactly the clone-reset lie.
1572
1825
  if head.startswith("---"):
1573
1826
  end = head.find("\n---", 3)
1574
1827
  if end != -1:
1575
- m = re.search(r"^date:\s*(\d{4}-\d{2}-\d{2})",
1576
- head[:end], re.M)
1828
+ m = re.search(
1829
+ r"^date:\s*[\"']?(\d{4}-\d{2}-\d{2})",
1830
+ head[:end], re.M)
1577
1831
  if m:
1578
1832
  stamp = _dt.date.fromisoformat(m.group(1))
1579
1833
  except Exception:
@@ -1581,20 +1835,51 @@ def kb_cmd_orient(argv):
1581
1835
  if stamp is None:
1582
1836
  try:
1583
1837
  stamp = _dt.date.fromtimestamp(p.stat().st_mtime)
1838
+ from_mtime = True
1584
1839
  except Exception:
1585
1840
  continue
1586
- if newest is None or stamp > newest:
1841
+ # Strict newer wins; on a tie a DATED stamp beats an mtime one,
1842
+ # so the disclosure below never fires beside a validated date
1843
+ # proving the same day.
1844
+ if (newest is None or stamp > newest
1845
+ or (stamp == newest and newest_mtime
1846
+ and not from_mtime)):
1587
1847
  newest = stamp
1848
+ newest_mtime = from_mtime
1588
1849
  if newest is None:
1589
1850
  print("\nno notes yet (corpus/notes)")
1590
1851
  else:
1591
1852
  days = (_dt.date.today() - newest).days
1592
- print("\nnewest note: %d days old (corpus/notes)" % max(days, 0))
1853
+ # The basis is disclosed when mtime decided the winner: the
1854
+ # field-reported lie was a rewritten note reading "0 days old"
1855
+ # against SKILL.md's date-first promise -- degraded, and mute.
1856
+ basis = "; mtime -- date: not parsed" if newest_mtime else ""
1857
+ print("\nnewest note: %d days old (corpus/notes%s)"
1858
+ % (max(days, 0), basis))
1593
1859
  except (Exception, SystemExit):
1594
1860
  # Fail-open: nothing in the recency append may break orient. (The
1595
1861
  # probe parser lives in the router try above; this guard covers the
1596
1862
  # date/mtime walk and the print itself.)
1597
1863
  pass
1864
+ try:
1865
+ # F-044: the time-cycle count line -- once per session at orient,
1866
+ # fail-open, SILENT when clean (noise is failure one). It runs the
1867
+ # shared collector, never a leaner walk: the counts must match what
1868
+ # `stale` lists, or the line under-counts its way to false calm.
1869
+ if root is not None:
1870
+ docs = root / name
1871
+ if (docs / "topics").is_dir() or (docs / "corpus").is_dir():
1872
+ cycle = kb_time_cycle(docs)
1873
+ n_docs = len({c[0] for c in cycle["cited"]})
1874
+ m_exp = len(cycle["expired"])
1875
+ k_chain = len(cycle["chain"])
1876
+ if n_docs or m_exp or k_chain:
1877
+ print("\nkb time: %d docs cite superseded claims; "
1878
+ "%d claims expired; %d derivations on superseded "
1879
+ "sources -- run sdlc_check.py stale"
1880
+ % (n_docs, m_exp, k_chain))
1881
+ except (Exception, SystemExit):
1882
+ pass
1598
1883
  return rc
1599
1884
 
1600
1885
 
@@ -1635,6 +1920,8 @@ def kb_cmd_help():
1635
1920
  knowledge overlay (kb-agentic) -- also available:
1636
1921
  graph topic-graph integrity: placement, edges, cycles
1637
1922
  corpus corpus integrity: digests, supersession, notes
1923
+ stale spine staleness PLUS `## claims`: superseded-cited
1924
+ docs, expired windows, superseded chains
1638
1925
  claim-id <path> <locator> compute a claim id (--fill to fill a whole table)
1639
1926
  anchor <path> <phrase> resolve a phrase to a verified locator span
1640
1927
  export --out <dir> bundle a subgraph WITH the bytes its claims cite
@@ -1679,6 +1966,10 @@ def main(argv=None):
1679
1966
  p.add_argument("--docs-dir")
1680
1967
  p.add_argument("--strict", action="store_true")
1681
1968
  p.add_argument("--hybrid", action="store_true")
1969
+ p = sub.add_parser("stale")
1970
+ p.add_argument("--root")
1971
+ p.add_argument("--docs-dir")
1972
+ p.add_argument("--hybrid", action="store_true")
1682
1973
  p = sub.add_parser("claim-id")
1683
1974
  p.add_argument("path")
1684
1975
  p.add_argument("locator", nargs="?")
@@ -1719,6 +2010,8 @@ def main(argv=None):
1719
2010
  return 1
1720
2011
  if args.cmd == "index":
1721
2012
  return kb_cmd_index(root, docs)
2013
+ if args.cmd == "stale":
2014
+ return kb_cmd_stale(root, docs, hybrid=args.hybrid)
1722
2015
  if args.cmd == "graph":
1723
2016
  return kb_cmd_graph(docs)
1724
2017
  if args.cmd == "corpus":
@@ -625,6 +625,9 @@ status: CURRENT
625
625
 
626
626
  - `id` empty when writing by hand: `sdlc_check.py claim-id --fill <file>` computes it.
627
627
  - `valid` half-open: `from` inclusive, `until` exclusive; `if <cond>` for conditionals.
628
+ Expiry against today is COMPUTED by the reports (`stale`'s `## claims`,
629
+ the orient count line) -- never written into `state`: state transitions
630
+ belong to reconciliation.
628
631
  - `qty`: `<value> <unit> <kind>` — effort in person-days (h/d/w/mo/fte-mo; 8h=1d, 1w=5d,
629
632
  1mo=21d), duration in calendar days (h/d/w/mo), cost within one currency, count unit-matched.
630
633
  - `about`: `<predicate> -> <slug>` for relationship claims; stored once, under the subject.