@antoneeo/kb-agentic-skill 1.22.0 → 1.24.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,18 @@
2
2
 
3
3
  Every significant change to this skill is recorded here.
4
4
 
5
+ ## [1.24.0] - 2026-10-17
6
+
7
+ ### Changed
8
+ - F-073: one written form for a dated entry, `**YYYY-MM-DD — label.**`, is taught for Diary entries, spike outcomes and project notes that record an event, and shared readers (`entry_openers`, `request_entries`, `adr_date`, `frontmatter_date`) let a chronology list those events the same way the validator reads them. Three new advisories, which never fail a build, not even `--strict`: undated text in the Diary of an L3 analysis started on or after 2026-10-18; a HANDOFF `next:` longer than 240 characters on an L3 file updated on or after that date (`next:` now holds only the next step, and the state of the work goes to the Diary); and, in `check` only, an open L3 unit whose id another branch or tag uses for a different analysis (one `git grep` over local refs, silent without git). The doctrine now says to choose a new unit's id after fetching, and that a session sharing a checkout with another session works in its own git worktree. A source sidecar's `date:` is now stated to be the day the source was received.
9
+
10
+ ## [1.23.0] - 2026-10-17
11
+
12
+ ### Changed
13
+ - F-068: on a live front, a new source or statement inside the declared `scope:` of an open unit (HANDOFF frontmatter) enters as an *ingestion step* of that unit: request entry, artifact or note, claim rows, reconciliation, `graph` until clean, one resume line. It does not reopen the full L3 cycle. Alignment (cascade on dependents, `stale`, registry, `index`, the `check` gate, commit) runs once, at an explicit closure the practitioner asks for or at session end. Two exceptions stay immediate: an in-flight draft is updated in the same step, and a sentence already delivered that a new fact falsifies is flagged in the same reply. Without isolation, a save commit marked `ingestion step` is allowed after a clean `graph`. Row locators come from `anchor`, ids from `claim-id --fill`. The Phase-5 instruction to update `handoff.md` by hand is gone: the registry is generated.
14
+ - F1: closing the last open workstream now empties the generated registry `audit/handoff.md` instead of leaving its stale row behind: with zero `HANDOFF_*.md` sources `index` rewrites it empty (keeping `project_notes.md`), and `validate` reports a generated registry that still has rows but no sources as an error.
15
+ - F-069: `graph`, `check`, `stale` and the session-start count are faster on a mature knowledge base, with byte-identical output. Citations of superseded claim ids are found with one pass per living document instead of one scan per (document, id) pair, and one check run reads and resolves each cited extraction once instead of once per claim row. On a synthetic ledger of 2183 rows and 1000 living documents, `graph` drops from 14 s to 4 s. No new flag.
16
+
5
17
  ## [1.22.0] - 2026-10-13
6
18
 
7
19
  ### Changed
package/README.md CHANGED
@@ -46,7 +46,7 @@ Deliberately absent: any per-node coverage or completion state. `gaps:` says wha
46
46
 
47
47
  ## Key features
48
48
 
49
- - **Risk-proportional triage, measured in knowledge and never in files**: one claim row → propagating a fact already settled → a new knowledge unit (a source ingested, a node created or superseded, the hierarchy moved). Carrying one settled fact into eight documents is small; one claim that re-parents a node is not. A **Write Triggers** table maps each knowledge event to exactly one destination.
49
+ - **Risk-proportional triage, measured in knowledge and never in files**: one claim row → propagating a fact already settled → a new knowledge unit (a source ingested, a node created or superseded, the hierarchy moved). Carrying one settled fact into eight documents is small; one claim that re-parents a node is not. On a live front, a new source or statement within an open unit's declared scope is an **ingestion step** of that unit — recorded on the ledger at once — while the rewrite of dependent documents, the registry and the full check run once, at a closure the practitioner calls. A **Write Triggers** table maps each knowledge event to exactly one destination.
50
50
  - **Portable knowledge**: `export` bundles a subgraph together with the bytes its claims cite — a closure, not a selection, because a claim whose source cannot be reopened is model knowledge arriving by another route. `import` merges it into another project **additively**: it never overwrites a node and never deletes, and claims already present are recognised by id rather than by comparing text, since the same artifact cited at the same span mints the same id in every project. Knowledge crosses the project boundary; **authority does not** — an imported ruling arrives as `prov: IMPORTED`, keeps its original `basis:`, and cannot settle a local disagreement until you re-ratify it.
51
51
  - **Several people, one project**: the workstream registry (`audit/handoff.md`) is **generated** from one file per open workstream, so two people opening or closing two workstreams on two branches edit two different files and their merge is clean. Row-per-workstream alone was not enough — a file-global `Date:` header defeats row-level ownership — so the header is derived from the sources and no writer touches it. The generated view can still conflict; that conflict is resolved by re-running `index`, never by hand, and `validate` refuses CLEAN until the file matches its sources. The append-only review log gets `merge=union` (a built-in driver, no per-clone configuration). It all works with no VCS at all: it is files and a generator.
52
52
  - **Vision-guided governance**: Standalone projects use `ai_docs/vision/`; Hybrid projects use devPNT `M-VISION` as the milestone north star. `DRAFT` informs, `APPROVED` binds, promotion is the user's alone.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kb-agentic-skill",
3
- "version": "1.22.0",
3
+ "version": "1.24.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.22.0",
3
+ "version": "1.24.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",
package/scripts/init.js CHANGED
@@ -196,16 +196,16 @@ in the skill's \`routing.md\`: it decides which lens's method and validation rul
196
196
  that unit. L1 never reaches it. Never refer to a document whose meaning differs by lens
197
197
  ("threat model", "vision", \`principles.md\`, \`handoff.md\`) by its bare name.
198
198
 
199
- Select the owning lens by the unit's purpose, then apply only its risk scale.
200
- The project's always-on pointer stays intact: consult the selected skill rather than
201
- merging multiple mandatory ladders. All lenses already carry shared project memory
202
- (\`memory.md\`); another installation is not needed for cross-domain recall.
199
+ Select the owning lens by the unit's purpose, then apply only its risk scale.
200
+ The project's always-on pointer stays intact: consult the selected skill rather than
201
+ merging multiple mandatory ladders. All lenses already carry shared project memory
202
+ (\`memory.md\`); another installation is not needed for cross-domain recall.
203
203
  This file is NOT auto-loaded by any client: it is a note for you, deliberately additive.
204
- Delete this note once the routing pointer is integrated.
204
+ Delete this note once the routing pointer is integrated.
205
205
  `, 'multi-lens routing note');
206
206
  if (wrote && protocolPreexisting) {
207
207
  console.log(' ⚠️ A protocol pointer already existed and was NOT overwritten.');
208
- console.log(' Integrate the routing pointer from AGENTIC_MULTI_LENS.md; keep one risk scale per unit.');
208
+ console.log(' Integrate the routing pointer from AGENTIC_MULTI_LENS.md; keep one risk scale per unit.');
209
209
  }
210
210
  }
211
211
 
@@ -47,8 +47,8 @@ The consolidated view is `sdlc_check.py stale` (the overlay intercepts
47
47
  the spine command, preserves its output, flags and exit code --
48
48
  `--hybrid` included -- and appends `## claims` only when nonempty). The
49
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.
50
+ and always fail-open. Event-driven by design: reconciliation §2 runs `stale` at the unit's
51
+ closure, once for every supersession since the last one; nothing polls per turn.
52
52
 
53
53
  ## 2. Check in CI (recommended for teams)
54
54
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: kb-agentic
3
- version: 1.22.0
3
+ version: 1.24.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
@@ -70,7 +70,8 @@ Cross-cutting rules:
70
70
  - **Domain routing (multi-lens installs only).** After the level is set, and only when a sibling lens skill of this family is installed (`agentic-sdlc`, `mkt-agentic-sdlc`, `course-creator`), run the router in `routing.md` for every L2, L3 and Spike: it decides which lens's method and validation rules govern this unit of work. L1 never reaches it, and a single-lens install never reads the file — detection fails open. In such a project, never refer to a document whose meaning differs by lens ("threat model", "vision", `principles.md`, `handoff.md`) by its bare name: qualify it with its domain, or name its path.
71
71
  - Personal data, credentials, security-sensitive processes, authN/authZ specs are high-risk: never L1.
72
72
  - **Escalation triggers — ANY of these makes it L3, whatever the file count:** the change touches the topic hierarchy (`parents:`, a `GENERALIZES` verdict, a re-parent); it touches more than one node's frontmatter; it creates or supersedes a node other nodes reference. Re-shaping the graph is a unit of change, never a side effect of placing one claim.
73
- - Adding one claim row to an existing topic is L1: the `id` may be left empty (the validator fills it — `claim-id --fill`), and no check errors on a hand-written row.
73
+ - Adding one claim row to an existing topic is L1. The locator comes from `anchor <path> <phrase>`, not from counting lines by hand except for a form `anchor` does not produce; the `id` is left empty and filled by `claim-id --fill` once, after the row is complete (`qty` included), and no check errors on a hand-written row. `graph` verifies that the span exists and that the id matches locator and `qty` — never that the span says what the claim says — so such a hand-counted locator is re-read at its span before the row is written.
74
+ - **An open unit absorbs.** A unit is open while its `audit/HANDOFF_*.md` exists, in the checkout its `branch:` names (from another checkout, its row in the registry says so); its scope is the `scope:` field of that HANDOFF's frontmatter — the file every open unit has by construction (a unit opened on a live front declares a front-wide scope there). A source or practitioner statement the scope covers is not a new unit: work in that checkout, declare `ingestion step of <unit>` in place of a new triage, and do only this — the request-record entry; the `given/` artifact with sidecar, or the note; the claim rows; the reconciliation classification, recording any supersession or contested set at once and presenting its escalations at the end of the step (a step is a run for `reconciliation.md` §4); `graph`, fixed until clean; one resume line in the HANDOFF body ("alignment pending since <date>: <what landed>"), frontmatter untouched. The step reply states what landed on the ledger, any escalation, any immediate exception (Phase 4), and that alignment is pending — this is how the practitioner knows the dependent documents lag. A step does not rewrite dependent documents, run `stale`, edit HANDOFF frontmatter or the registry, run `index` or `check`, or commit — that is the closure's work (Phase 5). One exception: on a project with no branch isolation (every session in one shared checkout), the step may end with a save commit once `graph` is clean, its message marked `ingestion step`, so history protects the work; such a commit has not passed the closure gate, which stays mandatory before the closure commit and before any push or integration. Without isolation the residual risk is higher — the save commit lands on the shared line itself, where other sessions read it before any closure gate, and another session's `index` or a sync tool can pick up uncommitted step files — and the step reply says so once per session. When coverage is doubtful, or two open units' scopes both cover it, ask one question before ingesting, naming the candidate units. When the step would fire an escalation trigger the approved ANALYSIS did not plan, stop, declare it, and amend the ANALYSIS with the practitioner's approval before continuing. No open unit, or outside every scope: triage as usual.
74
75
  - If a bigger impact emerges during L1/L2 work, stop, reclassify and declare it.
75
76
  - When in doubt, pick the higher level.
76
77
  - **Doubts asked, questions legal.** A real doubt is asked when it emerges, before its answer is written; every question passes the legality test of the question discipline (`elicitation.md`): searched first with the search named, and the blocked decision named. Claim-conflict escalations additionally carry their own mandated form and are batched at run end (`reconciliation.md` §4).
@@ -85,8 +86,8 @@ Triage decides IF documentation is due; this table decides WHICH document each e
85
86
  | `solutions/ANALYSIS_[topic].md` | Every L3, after elicitation and before any drafting. On topic match with an existing analysis, update that one instead of a new file. | 3 |
86
87
  | `solutions/SPIKE_[topic].md` | Closing any Spike — including a negative outcome. | — |
87
88
  | `vision/features/VISION_[feature].md` | Multi-milestone topic at analysis time, or retroactive trigger (second ANALYSIS on same theme). | 3 |
88
- | `audit/handoff.md` (workstream registry) | **Never by hand — generated by `sdlc_check.py index` from the `HANDOFF_*.md` sources**, and `validate` errors when the two disagree. Regenerate at every L3 closure and at session end; the `Date:` header is derived, so no writer touches it. Inventory for lookup, never a work board. | 5 / session end |
89
- | `audit/HANDOFF_[topic].md` | **One per OPEN workstream, with or without volatile state** — it is the authored home of that workstream's registry row (frontmatter `workstream`/`level`/`branch`/`status`/`since`/`next`/`details`/`updated`), so no file means no row. Carries the resume logistics too; the ANALYSIS Diary keeps the durable narrative (DRY). DELETED at closure — deleting it *is* removing the row. | 4 / 5 / session end |
89
+ | `audit/handoff.md` (workstream registry) | **Never by hand — generated by `sdlc_check.py index` from the `HANDOFF_*.md` sources**, and `validate` errors when the two disagree. Regenerate at every L3 closure (alignment or unit, Phase 5) and at session end — never at an ingestion step; the `Date:` header is derived, so no writer touches it. Inventory for lookup, never a work board. | 5 / session end |
90
+ | `audit/HANDOFF_[topic].md` | **One per OPEN workstream, with or without volatile state** — it is the authored home of that workstream's registry row (frontmatter `workstream`/`level`/`branch`/`status`/`since`/`next`/`details`/`updated`, plus `scope` for a unit that absorbs ingestion steps), so no file means no row. Its `next:` is the next concrete step in one sentence; what happened, and the state of the work, go to the Diary as a dated entry. Carries the resume logistics too; the ANALYSIS Diary keeps the durable narrative (DRY). Frontmatter updated at a closure or session end, never at an ingestion step (a step adds only its body resume line). DELETED at unit closure — deleting it *is* removing the row. | 4 / 5 / session end |
90
91
  | `audit/project_notes.md` | A note true for the whole project rather than one workstream. Appended verbatim to the generated registry, so regenerating cannot destroy notes that belong to no workstream. | 5 / session end |
91
92
  | `audit/handoff.md` — converting an existing project | **Lazily, at the first write — and then ALL AT ONCE.** Converting one row at a time is the state that loses the others, so `index` refuses to write while anything in the file is unaccounted for and names it. Until the first source exists nothing generates and nothing errors. | 5 / session end |
92
93
  | `audit/audit_plan.md` (Standalone) | Bootstrap, and whenever a mapped area changes state (`sdlc_check.py mark`). | 1 |
@@ -140,21 +141,25 @@ Use this mode when `devpnt_*` tools are available and point at the current proje
140
141
  - **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.
141
142
 
142
143
  ### 4. Knowledge Processing & Distillation
143
- - **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.
144
+ - **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. Where a project does not isolate (one shared checkout), that is a departure from this rule, not a second mode: Rule Zero's step save commit and its declared residual apply. A session that may run while another session uses the same checkout works in its **own git worktree**, and never switches branch, stashes, resets or runs `index` in a checkout another session may be using. Parallel work also shares ids: choose a new unit's id **after fetching**, as the first id free on every branch and tag the checkout can see (`check` names a collision while the unit is open).
144
145
  - 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).
145
146
  - Execute knowledge extraction using **Signal Distillation** (`distillation.md`).
146
147
  - **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. `dispatch.md` also carries the family-wide **delegation boundary** (what may be delegated at all, and what never may) — that section is not gated by the opt-in trigger and applies at any level, dispatch or no dispatch.
147
148
  - Handle conflicting or outdated information via **Reconciliation** (`reconciliation.md`). Mark obsolete files `status: SUPERSEDED`.
149
+ - **New information on an open unit arrives as ingestion steps** (Rule Zero, *An open unit absorbs*), and the dependent documents wait for the closure — with two exceptions that never wait: a communication draft in progress whose sentence a new fact changes is updated in the same step, and a new fact that makes false a sentence already delivered to the practitioner in this session is flagged in the same reply, naming the sentence and the fact. Across sessions what was delivered is unknowable; the HANDOFF resume line and the request record are what the next session reads.
148
150
 
149
151
  ### 5. Closure & Indexing
150
- - Run verification checks (`sdlc_check.py check --root <project_root>`).
151
- - If the session recorded any supersession, run `sdlc_check.py stale`: its
152
- `## claims` section is the revision worklist (superseded-cited documents,
153
- expired windows, superseded derivation chains), worked per `## Revision`
154
- -- one document, one full re-read.
155
- - 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.
156
- - Update `audit/handoff.md`.
157
- - Regenerate manifests: `python <skill_dir>/scripts/sdlc_check.py index --root <project_root>`.
152
+ - **Phase 5 fires only on the practitioner's request** ("allinea", "chiudi") or on a session-closing signal (the Capture Moment's); propose it when the planned work is done. A unit that absorbed no ingestion step closes as before, when its planned work is done. It covers everything since the last closure, enumerated from the request-record entries since then and the uncommitted changes on the unit's branch, and runs once, in this order:
153
+ - the Capture Moment sweep;
154
+ - `sdlc_check.py stale`: its `## claims` section is the revision worklist (superseded-cited documents, expired windows, superseded derivation chains);
155
+ - the revision, per `## Revision` — one document, one full re-read — of every document that worklist names and every document the enumerated entries affect;
156
+ - 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;
157
+ - the HANDOFF update (below), then `python <skill_dir>/scripts/sdlc_check.py index --root <project_root>`;
158
+ - the closure gate, `sdlc_check.py check --root <project_root>` — the only gate command: it already runs `validate`, `graph` and `corpus`, so those are not run separately before it. Its stale stage is the spine's and does not print the kb `## claims` worklist (expired windows above all), which is why the `stale` step above stays — then the commit.
159
+ - **Alignment closure** — the unit stays open: update the HANDOFF frontmatter and body to current state; commit on the unit's branch and propose its integration, so the day's ledger reaches the shared line the same day; the integration choice is the practitioner's.
160
+ - **Unit closure** — the practitioner declares the work done: run the closure review (`review.md`) before DONE, then delete the HANDOFF; deleting it *is* removing the registry row, and the registry is never edited by hand. When it was the last one, `index` empties the generated registry.
161
+ - When the closure gate is red: no commit; the unit stays open; name the failing checks, fix, re-run.
162
+ - The closure reply states the closure-gate result, the documents revised, and the commit — or, when red, that nothing was committed.
158
163
  - Mark output clean and complete.
159
164
 
160
165
  ## Operative Guides & Router
@@ -233,10 +238,14 @@ Capture today is what makes recall honest tomorrow: a captured elicited or ruled
233
238
  is exactly the non-DERIVED ground the re-touch rule above walks to. The rails all
234
239
  exist — this section adds only the MOMENT.
235
240
 
236
- - **When — user-signed closings, once per session.** Phase 5 of a governed unit, or
241
+ - **When — user-signed closings, once per session** — and at every Phase 5, so
242
+ also again at the session's closing when an alignment closure already ran earlier
243
+ in it; the sweep is idempotent, so a later run only adds what came after the
244
+ earlier one. Phase 5 of a governed unit, or
237
245
  any closing signal from the user in an un-governed session ("chiudiamo", "we're
238
- done"); the session-end pass that updates `handoff.md`/`project_notes.md` then runs
239
- at that same closing. Never per-turn, never a standing interruption. **Declared
246
+ done"); the session-end pass that updates the `HANDOFF_*.md` files and
247
+ `project_notes.md`, then regenerates the registry with `index`, runs at that same
248
+ closing. Never per-turn, never a standing interruption. **Declared
240
249
  residual**: an unsignaled end (the user simply stops writing) captures nothing —
241
250
  the phase-4 duty (a decision stated mid-session owes its note WHEN SAID) is the
242
251
  protection for sessions that never close, and the orient's recency line makes the
@@ -41,14 +41,18 @@ 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.
44
+ 3. **The cascade runs.** The supersession is recorded in the ledger at
45
+ once; the cascade runs at the unit's next closure (`SKILL.md` Phase 5),
46
+ once for every supersession since the last one: `sdlc_check.py stale`,
47
+ whose `## claims` section lists every living document still citing the
48
+ fallen id without naming its successor(s) and every derivation resting
49
+ on a superseded source -- the revision worklist, worked per the kb
50
+ `## Revision` doctrine, one document at a time. The list is derived from
51
+ the ledger's state, so running it later loses nothing; meanwhile the
52
+ citation warn stands in `check` until each document is revised (or an
53
+ append-only correction naming the successors lands), so the worklist
54
+ cannot be silently forgotten. What does not wait for the closure is
55
+ named once, in `SKILL.md` Phase 4 (the two immediate exceptions).
52
56
 
53
57
  **No basis, no ruling.** A preference is not a fact; the validator refuses a ruling note
54
58
  without `basis:` exactly as it refuses a DERIVED note without `derived_from:`. If the
@@ -125,6 +125,21 @@ def kb_unquote(value):
125
125
  return v
126
126
 
127
127
 
128
+ def frontmatter_date(meta):
129
+ """A note's or a sidecar's `date:` as `YYYY-MM-DD`, or None (F-073).
130
+ Quotes stripped, a datetime suffix tolerated, an impossible date refused --
131
+ the one parser behind orient's recency probe and any chronology reader."""
132
+ m = re.match(r"(\d{4}-\d{2}-\d{2})", kb_unquote((meta or {}).get("date")))
133
+ if not m:
134
+ return None
135
+ try:
136
+ import datetime as _dt
137
+ _dt.date.fromisoformat(m.group(1))
138
+ except ValueError:
139
+ return None
140
+ return m.group(1)
141
+
142
+
128
143
  def kb_parse_scope(text):
129
144
  """'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
130
145
  ('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
@@ -336,6 +351,17 @@ def kb_check_claims(root):
336
351
  if rel not in fm_cache:
337
352
  fm_cache[rel] = _note_frontmatter(root, rel)
338
353
  return fm_cache[rel]
354
+ # F-069: the same per-run memo for the source paths themselves -- each
355
+ # distinct path is resolved and stat'ed once, and each cited extraction
356
+ # read once (loc_cache, owned here, dropped when this call returns).
357
+ src_cache = {}
358
+ loc_cache = {}
359
+
360
+ def _resolved(path_s):
361
+ if path_s not in src_cache:
362
+ t = sdlc_core.confine_under(root, path_s)
363
+ src_cache[path_s] = (t, t is not None and t.is_file())
364
+ return src_cache[path_s]
339
365
  per_file_rows = []
340
366
  for p in sorted(topics.glob("*.md")):
341
367
  rel = "topics/" + p.name
@@ -357,16 +383,16 @@ def kb_check_claims(root):
357
383
  errors.append("%s: source %r has no locator" % (where, src))
358
384
  continue
359
385
  path_s, loc = src.rsplit("#", 1)
360
- target = sdlc_core.confine_under(root, path_s)
386
+ target, is_file = _resolved(path_s)
361
387
  if target is None:
362
388
  errors.append("%s: source %r escapes the docs root" % (where, src))
363
389
  continue
364
- if not target.is_file():
390
+ if not is_file:
365
391
  errors.append("%s: source %r does not resolve — a claim whose "
366
392
  "origin cannot be reopened is model knowledge"
367
393
  % (where, src))
368
394
  continue
369
- kb_check_locator(target, loc, where, errors)
395
+ kb_check_locator(target, loc, where, errors, loc_cache)
370
396
  # --- provenance ---
371
397
  prov = row["prov"]
372
398
  # Resolved ONCE, for every provenance: the non-GIVEN classes read it
@@ -556,8 +582,14 @@ def kb_check_claims(root):
556
582
  return errors, warnings, notes
557
583
 
558
584
 
559
- def kb_check_locator(target, loc, where, errors):
560
- """A locator must address an existing span of the stored bytes."""
585
+ def kb_check_locator(target, loc, where, errors, cache=None):
586
+ """A locator must address an existing span of the stored bytes.
587
+
588
+ `cache` is an optional dict the CALLER owns for one run (F-069): keyed by
589
+ the path actually read, it holds only what the checks compare against --
590
+ page lengths, a line count, or False for a missing extraction -- never the
591
+ text. Findings are still appended per call, so per row. Without it, every
592
+ call reads the file, as before."""
561
593
  m = LOC_PAGE_RE.match(loc)
562
594
  if m:
563
595
  page, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
@@ -566,17 +598,24 @@ def kb_check_locator(target, loc, where, errors):
566
598
  return
567
599
  # offsets address the stored extraction (<stem>.txt beside the original)
568
600
  ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
569
- if not ext.is_file():
601
+ key = ("p", ext)
602
+ if cache is None or key not in cache:
603
+ lengths = ([len(pg) for pg in sdlc_core.read_text(ext).split("\f")]
604
+ if ext.is_file() else False)
605
+ if cache is not None:
606
+ cache[key] = lengths
607
+ else:
608
+ lengths = cache[key]
609
+ if lengths is False:
570
610
  errors.append("%s: no stored extraction %s for locator %r — offsets "
571
611
  "must address kept bytes" % (where, ext.name, loc))
572
612
  return
573
- pages = sdlc_core.read_text(ext).split("\f")
574
- if page < 1 or page > len(pages):
613
+ if page < 1 or page > len(lengths):
575
614
  errors.append("%s: locator %r addresses page %d of %d" %
576
- (where, loc, page, len(pages)))
577
- elif b > len(pages[page - 1]):
615
+ (where, loc, page, len(lengths)))
616
+ elif b > lengths[page - 1]:
578
617
  errors.append("%s: locator %r spans past the end of page %d "
579
- "(%d chars)" % (where, loc, page, len(pages[page - 1])))
618
+ "(%d chars)" % (where, loc, page, lengths[page - 1]))
580
619
  return
581
620
  m = LOC_LINE_RE.match(loc)
582
621
  if m:
@@ -584,7 +623,13 @@ def kb_check_locator(target, loc, where, errors):
584
623
  if a > b or a < 1:
585
624
  errors.append("%s: locator %r has an invalid line range" % (where, loc))
586
625
  return
587
- n = sdlc_core.read_text(target).count("\n") + 1
626
+ key = ("L", target)
627
+ if cache is None or key not in cache:
628
+ n = sdlc_core.read_text(target).count("\n") + 1
629
+ if cache is not None:
630
+ cache[key] = n
631
+ else:
632
+ n = cache[key]
588
633
  if b > n:
589
634
  errors.append("%s: locator %r spans past line %d" % (where, loc, n))
590
635
  return
@@ -1085,12 +1130,18 @@ KB_GENERATED_RE = re.compile(r"GENERATED by \S+ index - do not edit by hand")
1085
1130
  KB_HEX12_RE = re.compile(r"^[0-9a-f]{12}$")
1086
1131
 
1087
1132
 
1088
- def _kb_hex_bounded(text, cid):
1089
- """True when cid appears NOT embedded in a longer hex run: a git hash
1090
- sharing a substring must never count as a citation (either side, either
1091
- case). cid is pre-validated 12-hex, so it is regex-safe by construction."""
1092
- return re.search(r"(?<![0-9a-fA-F])%s(?![0-9a-fA-F])" % cid,
1093
- text) is not None
1133
+ # A citation of a claim id is a run of exactly its 12 lowercase hex characters
1134
+ # NOT embedded in a longer hex run: a git hash sharing a substring must never
1135
+ # count as a citation (either side, either case). Two such bounded runs cannot
1136
+ # overlap, so findall returns every one of them.
1137
+ KB_HEX_TOKEN_RE = re.compile(r"(?<![0-9a-fA-F])[0-9a-f]{12}(?![0-9a-fA-F])")
1138
+
1139
+
1140
+ def _kb_hex_tokens(text):
1141
+ """The ids a text cites, extracted in one pass (F-069). Callers test
1142
+ membership instead of scanning the text once per id: that per-(document,
1143
+ id) scan made the time cycle grow with superseded ids x living documents."""
1144
+ return frozenset(KB_HEX_TOKEN_RE.findall(text))
1094
1145
 
1095
1146
 
1096
1147
  def _kb_df_entries(value):
@@ -1172,19 +1223,23 @@ def kb_time_cycle(docs):
1172
1223
  if (meta.get("status") or "").strip() == "SUPERSEDED":
1173
1224
  continue
1174
1225
  living.append((rel, text))
1175
- for rel, text in living:
1176
- for old in sorted(superseded):
1177
- if _kb_hex_bounded(text, old):
1178
- succs = superseded[old]
1179
- missing = tuple(x for x in succs
1180
- if not _kb_hex_bounded(text, x))
1181
- if missing:
1182
- cited.append((rel, old, missing, succs))
1226
+ # One token pass per living document, and none at all on a KB with no
1227
+ # supersession and no validity window (the orient line runs this at every
1228
+ # session start).
1229
+ tokens = ({rel: _kb_hex_tokens(text) for rel, text in living}
1230
+ if superseded or windows else {})
1231
+ for rel, _text in living:
1232
+ cites = tokens[rel] if tokens else frozenset()
1233
+ for old in cites.intersection(superseded):
1234
+ succs = superseded[old]
1235
+ missing = tuple(x for x in succs if x not in cites)
1236
+ if missing:
1237
+ cited.append((rel, old, missing, succs))
1183
1238
  today = _dt.date.today().isoformat()
1184
1239
  for topic_rel, rid, until in sorted(windows):
1185
1240
  if until <= today:
1186
- by = tuple(rel for rel, text in living
1187
- if rel != topic_rel and _kb_hex_bounded(text, rid))
1241
+ by = tuple(rel for rel, _text in living
1242
+ if rel != topic_rel and rid in tokens[rel])
1188
1243
  expired.append((topic_rel, rid, until, by))
1189
1244
  rev = {}
1190
1245
  node_meta = {}
@@ -1905,14 +1960,13 @@ def kb_cmd_orient(argv):
1905
1960
  # it -- the file was already fully read, so the cap bought
1906
1961
  # only failure. load_frontmatter is the authoritative
1907
1962
  # reader (fence-bounded, so a column-0 date: in the body
1908
- # still cannot misdate the note); kb_unquote covers quoted
1909
- # dates; the prefix match tolerates a datetime suffix.
1963
+ # still cannot misdate the note); frontmatter_date covers
1964
+ # quoted dates and tolerates a datetime suffix.
1910
1965
  meta = sdlc_core.load_frontmatter(
1911
1966
  sdlc_core.read_text(p).splitlines()) or {}
1912
- m = re.match(r"(\d{4}-\d{2}-\d{2})",
1913
- kb_unquote(meta.get("date")))
1914
- if m:
1915
- stamp = _dt.date.fromisoformat(m.group(1))
1967
+ day = frontmatter_date(meta)
1968
+ if day:
1969
+ stamp = _dt.date.fromisoformat(day)
1916
1970
  except Exception:
1917
1971
  pass
1918
1972
  if stamp is None: