@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 +54 -0
- package/README.md +1 -1
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/skills/kb-agentic-skill/ENFORCEMENT.md +39 -1
- package/skills/kb-agentic-skill/SKILL.md +9 -2
- package/skills/kb-agentic-skill/reconciliation.md +25 -16
- package/skills/kb-agentic-skill/scripts/sdlc_check.py +309 -16
- package/skills/kb-agentic-skill/templates.md +3 -0
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 `
|
|
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
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kb-agentic-skill",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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
|
|
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.
|
|
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`, `
|
|
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 (
|
|
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")
|
|
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")
|
|
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")
|
|
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
|
-
|
|
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,
|
|
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)" %
|
|
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(
|
|
1576
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|