@antoneeo/kb-agentic-skill 1.9.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,80 @@
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
+
59
+ ## [1.10.0] - 2026-08-28
60
+
61
+ F-043 -- the revision doctrine: documents read as current state.
62
+
63
+ ### Added
64
+ - **`## Revision — the full re-read` (SKILL.md).** Incorporating new knowledge
65
+ into an existing document is a rewrite, not an append: full re-read first,
66
+ body rewritten to current state, history in the Diary (past entries never
67
+ rewritten -- a correction is a new entry), claim tables exempt (rows move only
68
+ per reconciliation), node frontmatter exempt (a revision never re-parents),
69
+ and a multi-document pass re-reads PER DOCUMENT. Wired where the trigger
70
+ fires: the L2 triage row and the L3 Phase-4 flow (citation-only pointers);
71
+ the honest limit is in the doctrine's own text. Born from a field post-mortem
72
+ (12 documents realigned by delta-append; stale sentences left standing) --
73
+ and completed by its own review: three rounds, each catching the doctrine's
74
+ failure mode inside its own ANALYSIS.
75
+ - **The `remind` line carries the gesture** ("Re-read it whole and rewrite it
76
+ to current state ..."): 443 chars, ~75 tokens per prompt, still one constant
77
+ zero-read line.
78
+
5
79
  ## [1.9.0] - 2026-08-28
6
80
 
7
81
  F-042 -- the install wires the hook; the incomprehensible question dies.
package/README.md CHANGED
@@ -24,6 +24,8 @@ Two axes are kept apart on purpose — **abstraction** (a topic made of topics:
24
24
 
25
25
  7. **Capture — the day's decisions reach the ledger before they evaporate.** At a user-signed closing ("chiudiamo"), one sweep question — carrying its own inline result: "today I recorded X and Y — any other decisions from today?" — reaches even the decisions taken outside the session, which no transcript search could find. What surfaces lands on the existing rails (an elicited dated note, an L1 claim row) with explicit provenance; the sweep is idempotent (nothing captured twice), never creates topics, and stays silent when no one signed off. The kb `orient` shows notes recency at every session start — dated from note frontmatter, because file mtimes lie after a clone or worktree — so a missed capture is visible the next morning.
26
26
 
27
+ 8. **Revision — documents read as current state.** Incorporating new knowledge into an existing document is a full re-read and a body rewrite, not a stack of dated deltas: anchored edits are each correct while the document as a whole rots. The doctrine keeps history in the Diary (past entries never rewritten), leaves claim tables to reconciliation and graph edges to the taxonomy, and runs per document even in a multi-document pass. Born from a real field post-mortem: twelve documents realigned by append, every untouched sentence left standing as printed truth.
28
+
27
29
  Deliberately absent: any per-node coverage or completion state. `gaps:` says what a node lacks; nothing collects it into a dashboard. A **source** does record how far it has been read, but on its own sidecar and nowhere else — the corpus index prints that fact for every artifact, including the finished ones, because a list of only what is behind is the dashboard this method refuses.
28
30
 
29
31
  ## Key features
@@ -35,7 +37,7 @@ Deliberately absent: any per-node coverage or completion state. `gaps:` says wha
35
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".
36
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.
37
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.
38
- - **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.
39
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).
40
42
 
41
43
  ## Installation
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kb-agentic-skill",
3
- "version": "1.9.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.9.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
 
@@ -169,8 +207,8 @@ Gemini CLI — wire the same command into its startup-hook mechanism if present;
169
207
  `orient` fires once, at session start; a long session drifts after it (a compaction,
170
208
  ten turns of other work -- the F-041 field report). `sdlc_check.py remind` prints ONE
171
209
  constant line -- the kb minimum: the triage levels, the topic-index recall trigger, the
172
- capture moment -- for the client to inject at every prompt. Cost: ~50 tokens per
173
- prompt, which is exactly why `init` never wires it and never will by default: the
210
+ capture moment, the revision gesture -- for the client to inject at every prompt.
211
+ Cost: 443 characters, ~75 tokens per prompt, which is exactly why `init` never wires it and never will by default: the
174
212
  per-turn cost is the project's choice. The line is constant and zero-read, so nothing
175
213
  repo-controlled can ride into the agent's context through it, and the vision's
176
214
  no-per-turn-graph-queries non-goal stays honored -- the line re-arms the
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: kb-agentic
3
- version: 1.9.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.
@@ -46,7 +46,7 @@ Always classify the request before choosing the process. Declare the chosen leve
46
46
  | Level | Criteria | Required process |
47
47
  |---|---|---|
48
48
  | **L1 - Quick Fact / Snippet** | One claim row added to an existing topic; a typo; a preference update. No node created, no source entering the corpus, no frontmatter change. | Implement edit directly in existing note. No new documents. |
49
- | **L2 - Propagation of settled knowledge** | The fact is **already settled** in the corpus and the work carries it into existing documents — restating, correcting a stale copy, updating an SOP that quotes it. No node created or superseded, no hierarchy change, no node frontmatter change, no new source ingested. | Mini-analysis in message: objective, impact, sources, validation. Update existing document or create single SOP/note. |
49
+ | **L2 - Propagation of settled knowledge** | The fact is **already settled** in the corpus and the work carries it into existing documents — restating, correcting a stale copy, updating an SOP that quotes it. No node created or superseded, no hierarchy change, no node frontmatter change, no new source ingested. | Mini-analysis in message: objective, impact, sources, validation. Update existing document (full re-read per `## Revision`) or create single SOP/note. |
50
50
  | **L3 - New knowledge unit / Corpus** | A source enters the corpus; a topic node is created or superseded; the hierarchy moves; a conflict must be reconciled; or what a claim asserts changes. | Full workflow: Vision Gate, Spec Elicitation, Taxonomy Pass, Knowledge Analysis, Distillation, Review, Indexing. |
51
51
  | **Spike - Exploration** | Time-boxed exploratory research or draft without merging into official KB. | Outcome in `ai_docs/solutions/SPIKE_[topic].md`. |
52
52
 
@@ -124,7 +124,7 @@ Use this mode when `devpnt_*` tools are available and point at the current proje
124
124
  - **Design review gate (end of Phase 3, before any drafting):** the analysis is reviewed by somebody other than its author — a subagent with fresh context, or a declared self-pass when no higher rung is usable — a permission-gated rung is asked about, never silently skipped (`review.md`). Follow `review.md`; log the outcome in `audit/reviews/REVIEW_LOG.md`. A knowledge structure reviewed only by the person who chose it is not reviewed.
125
125
 
126
126
  ### 4. Knowledge Processing & Distillation
127
- - **Isolate the work (Branch/worktree hygiene).** Distillation rewrites existing notes: do it on a branch or a worktree, never directly on the shared corpus, so a half-finished reconciliation is never what the next reader finds.
127
+ - **Isolate the work (Branch/worktree hygiene).** Distillation rewrites existing notes — full re-read per `## Revision` — and does it on a branch or a worktree, never directly on the shared corpus, so a half-finished reconciliation is never what the next reader finds.
128
128
  - Before drafting (L2/L3; L1 exempt), **consult the guide router** for a guide covering the task and read it first (the consult trigger, `guides.md` §0). A targeted description match, not a blanket read. Its result is the router verdict already declared with the triage level (Rule Zero).
129
129
  - Execute knowledge extraction using **Signal Distillation** (`distillation.md`).
130
130
  - **Opt-in subagent execution**: for an L3 with an approved analysis, the work MAY be executed via subagents per `dispatch.md`; default stays same-session.
@@ -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>`.
@@ -243,7 +247,28 @@ exist — this section adds only the MOMENT.
243
247
  - **Silence stays legal where no one signed off**: no closing signal → no question,
244
248
  no ritual line.
245
249
 
250
+ ## Revision — the full re-read
251
+
252
+ Incorporating new knowledge into an existing document is a rewrite, not an
253
+ append: anchored edits are each correct while the global invariant — the body
254
+ reads as CURRENT state — is never re-verified. The gesture:
255
+ - Re-read the WHOLE document before editing; then rewrite the body so it reads
256
+ as written today — new knowledge fused, never quarantined in dated
257
+ sections — never append a delta.
258
+ - History goes to the Diary/notes, never an inline chronicle — and past Diary
259
+ entries are never rewritten: a correction is a new entry.
260
+ - A SUPERSEDED banner is a tombstone, never a revision device in a living doc.
261
+ - Claim tables are EXEMPT (rows move only per `reconciliation.md`) and node
262
+ frontmatter moves only per `taxonomy.md` — a revision never re-parents; the
263
+ rewrite governs prose, never the ledger or the graph edges.
264
+ - A multi-document pass re-reads PER DOCUMENT; set coverage is not integrity.
265
+ - The re-read raises the probability of catching a false premise; it cannot
266
+ guarantee it — domain judgment stays the human's.
267
+
246
268
  ## Mechanical Enforcement
247
269
  - `scripts/sdlc_check.py index`: updates `ai_docs/INDEX.md` and `ai_docs/reference/INDEX.md`.
248
270
  - `scripts/sdlc_check.py validate`: validates YAML frontmatter (`status`, `description`).
249
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
 
@@ -1602,7 +1887,7 @@ def kb_cmd_remind(_argv):
1602
1887
  """F-041 item B: the per-turn one-line reminder (UserPromptSubmit, opt-in).
1603
1888
 
1604
1889
  A CONSTANT, zero-read line -- the kb minimum an agent needs re-armed every
1605
- prompt: triage, the recall trigger, the capture moment. Constant by
1890
+ prompt: triage, the recall trigger, the capture moment, the revision gesture. Constant by
1606
1891
  contract: nothing repo-controlled may ride a line injected into the agent's
1607
1892
  context every turn, and zero reads is what keeps the per-turn cost flat.
1608
1893
  The vision's non-goal bans per-turn GRAPH queries; this line only re-arms
@@ -1614,7 +1899,7 @@ def kb_cmd_remind(_argv):
1614
1899
  "settled knowledge, L3 new knowledge unit, Spike exploration; declare "
1615
1900
  "the level. Answering about this project's domain? Read topics/INDEX.md "
1616
1901
  "under the docs root first and cite claim ids, or state no coverage. "
1617
- "Decisions made today get recorded in the KB at session close.")
1902
+ "Decisions made today get recorded in the KB at session close. Revising an existing document? Re-read it whole and rewrite it to current state -- never append a delta.")
1618
1903
  return 0
1619
1904
 
1620
1905
 
@@ -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.