@antoneeo/kb-agentic-skill 1.2.0 → 1.4.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 +136 -0
- package/README.md +89 -86
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/scripts/init.js +28 -0
- package/skills/kb-agentic-skill/SKILL.md +8 -4
- package/skills/kb-agentic-skill/distillation.md +52 -4
- package/skills/kb-agentic-skill/scripts/sdlc_check.py +185 -17
- package/skills/kb-agentic-skill/scripts/sdlc_core.py +199 -3
- package/skills/kb-agentic-skill/templates.md +118 -33
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,142 @@
|
|
|
2
2
|
|
|
3
3
|
Every significant change to this skill is recorded here.
|
|
4
4
|
|
|
5
|
+
## [1.4.0] - 2026-08-03
|
|
6
|
+
|
|
7
|
+
F-028 — several people on one project. `templates.md` had claimed the workstream
|
|
8
|
+
registry was "Parallel-safe by construction" since F-019, and nothing had ever exercised
|
|
9
|
+
it. Two workstreams opened from one base conflict **twice in one file**: on the row
|
|
10
|
+
insert, and on the file-global `Date:` header. Row-level ownership cannot save a file
|
|
11
|
+
that has a file-level field.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- **`audit/handoff.md` is now GENERATED** from one `HANDOFF_[unit].md` per open
|
|
15
|
+
workstream, whose frontmatter IS the row. Two writers on two workstreams touch two
|
|
16
|
+
different files, and the `Date:` header is derived — from the newest `updated:` VALUE
|
|
17
|
+
in the sources, never a filesystem timestamp (git does not preserve mtimes, and an
|
|
18
|
+
mtime-derived header would make the file regenerate differently in every fresh clone).
|
|
19
|
+
- **`HANDOFF_[unit].md` is now written for every OPEN workstream**, with or without
|
|
20
|
+
volatile state — no file, no row. It still carries the resume logistics, and deleting
|
|
21
|
+
it at closure *is* what removes the row. The DRY boundary against the ANALYSIS Diary
|
|
22
|
+
is restated in the template, because what used to keep narrative out of that file was
|
|
23
|
+
its rarity, and the rarity is gone.
|
|
24
|
+
- Project-wide notes move to their own source, `audit/project_notes.md`, so generating
|
|
25
|
+
the registry cannot destroy notes that belong to no workstream.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- **`validate` errors when the registry disagrees with its sources** — which is what
|
|
29
|
+
turns a merge resolved carelessly from permanent into loud. Resolution is mechanical:
|
|
30
|
+
re-run `index`. The generated view can still conflict; the authored truth does not.
|
|
31
|
+
- **`index` refuses to write while anything in the file is unaccounted for**, and names
|
|
32
|
+
it. Converting one row at a time is the state that loses the others, so conversion is
|
|
33
|
+
per project. A project with no sources is untouched and sees no new finding.
|
|
34
|
+
- **`init` writes a `.gitattributes` stanza** giving the append-only review log
|
|
35
|
+
`merge=union` — a **built-in** driver, unlike `merge=ours`, which silently does
|
|
36
|
+
nothing until every clone runs `git config` and leaves the file wrong even then.
|
|
37
|
+
Create-only; a user's own `.gitattributes` is never clobbered.
|
|
38
|
+
- `scripts/test_merge_safety.py` (shared, ×3): the experiment that found the defect,
|
|
39
|
+
kept as a regression test — two workstreams from one base, merged, with the assertion
|
|
40
|
+
that only the generated view may conflict and that regenerating loses no state. Plus
|
|
41
|
+
the mixed-state, fresh-mtime, ordering, cap and duplicate-id guards, each
|
|
42
|
+
mutation-verified.
|
|
43
|
+
- A warning when two files claim the same workstream: the collision this design does
|
|
44
|
+
**not** fix (two people opening the same work under two names) must not pass as two
|
|
45
|
+
ordinary rows.
|
|
46
|
+
|
|
47
|
+
The mechanism is files and a generator: **it works with no VCS at all.** The
|
|
48
|
+
`.gitattributes` stanza is defence in depth — without it the outcome is today's, one
|
|
49
|
+
conflict resolved by hand, never a lost row.
|
|
50
|
+
|
|
51
|
+
### Upgrading
|
|
52
|
+
Nothing happens until you convert: with no `HANDOFF_*.md` sources, `index` and
|
|
53
|
+
`validate` behave exactly as before. When you convert, convert the whole registry at
|
|
54
|
+
once — `index` will tell you what is still unaccounted for.
|
|
55
|
+
|
|
56
|
+
## [1.3.0] - 2026-08-03
|
|
57
|
+
|
|
58
|
+
Field defect: an agent handed a 200-page manual emits a few dozen claims and reports
|
|
59
|
+
done. **Nothing it did broke a rule** — the extraction discipline carried a floor ("the
|
|
60
|
+
extractor invents nothing") and no target, so an agent stops the moment nothing it wrote
|
|
61
|
+
is false, and every row it emitted is correct.
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
- **A north star above the rules** in `distillation.md`: *not one assertion the source
|
|
65
|
+
makes may be lost, and not one it does not make may appear.* One sentence on purpose —
|
|
66
|
+
the halves counterweight each other, and two rules a paragraph apart get optimized
|
|
67
|
+
whichever was read last. The unit is the assertion, not the byte: exhaustive means
|
|
68
|
+
**read**, never *a row per page*.
|
|
69
|
+
- **`extracted_through:`** on the artifact's sidecar (`p=<n>`, `L<n>`, `complete`),
|
|
70
|
+
required once any claim cites it — the thing that makes "I am finished" falsifiable.
|
|
71
|
+
Claims with no coverage recorded error; a claim addressing past the declared coverage,
|
|
72
|
+
or coverage past the end of the stored bytes, is a contradiction and errors; coverage
|
|
73
|
+
short of the end warns, since partial work is legal mid-ingestion.
|
|
74
|
+
- **Bounded reading windows** (30 pages by default; the plan states the window used),
|
|
75
|
+
one plan task each. The existing `PLAN_` ledger is the register an ingestion resumes
|
|
76
|
+
from across sessions — no second register was built.
|
|
77
|
+
- **A coverage cell in `corpus/INDEX.md` for every artifact**, finished ones included: a
|
|
78
|
+
list of only what is behind would be the work-management dashboard this method refuses.
|
|
79
|
+
|
|
80
|
+
The limit is written where the field is: **nothing proves a page was read.** A field
|
|
81
|
+
advanced without extracting is invisible to any checker, because a page that asserts
|
|
82
|
+
nothing legitimately yields no rows — that direction belongs to the ingestion review.
|
|
83
|
+
What changed is that the shortcut must be written down to pass.
|
|
84
|
+
|
|
85
|
+
**Upgrading an existing corpus:** `check` errors on every artifact that has claims and
|
|
86
|
+
no `extracted_through:`. State how far each source was actually read (`complete` if it
|
|
87
|
+
was finished); the message names the artifact and the first row citing it. Run
|
|
88
|
+
`sdlc_check.py index` once as well — `corpus/INDEX.md` gains the coverage cell.
|
|
89
|
+
|
|
90
|
+
## [1.2.0] - 2026-08-03
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
- **`export --out <dir>` / `import <dir>`** — knowledge built in one project can be
|
|
94
|
+
carried into another. The export is a **closure**, not a selection: the bundle carries
|
|
95
|
+
the bytes its claims cite (a claim whose source cannot be reopened is model knowledge
|
|
96
|
+
arriving by another route) and pulls in the other half of any `CONTESTED` set, saying
|
|
97
|
+
which topics it added. The import is **additive and all-or-nothing**: it never
|
|
98
|
+
overwrites a node, never deletes, and computes the whole plan before writing a byte.
|
|
99
|
+
Duplicate claims are recognised by id, not by comparing text.
|
|
100
|
+
- **`prov: IMPORTED`.** Knowledge crosses the project boundary; authority does not. An
|
|
101
|
+
imported ruling keeps its text, span and original `basis:` verbatim, must declare
|
|
102
|
+
`imported_from:`, and **cannot supersede a local row** until you re-ratify it with your
|
|
103
|
+
own note and your own basis.
|
|
104
|
+
- **`portability.md`** — the doctrine those two commands cite, including what to tell the
|
|
105
|
+
user in their own words.
|
|
106
|
+
|
|
107
|
+
## [1.1.1] - 2026-08-03
|
|
108
|
+
|
|
109
|
+
### Fixed
|
|
110
|
+
Three doctrine-vs-machinery inconsistencies found by a practitioner reading 1.1.0 —
|
|
111
|
+
the worst defect class, because the agent verifies and is confirmed in a false belief.
|
|
112
|
+
- `SKILL.md` never named `anchor`, so the command existed and the agent could not find it.
|
|
113
|
+
- The `corpus/given/*` Write Trigger still carried its pre-1.1.0 wording, contradicting
|
|
114
|
+
the extraction-as-artifact rule it points at.
|
|
115
|
+
- `anchor` resolved paths only from inside the docs root, unlike every sibling command.
|
|
116
|
+
|
|
117
|
+
## [1.1.0] - 2026-08-03
|
|
118
|
+
|
|
119
|
+
### Fixed / Added
|
|
120
|
+
Six findings from the first full application of this skill by a practitioner other than
|
|
121
|
+
its author (51 artifacts, 82 claims). None was an adherence failure: the agent obeyed
|
|
122
|
+
every rule and the outcome was still wrong.
|
|
123
|
+
- **Triage restated in knowledge units.** The levels were undecidable in this domain
|
|
124
|
+
because they carried the code lens's file counts. The unit here is knowledge, never
|
|
125
|
+
file count — with one limit: propagation that changes what a claim asserts is not
|
|
126
|
+
propagation.
|
|
127
|
+
- **Gates are extracted alongside powers.** For every row saying what the subject *can
|
|
128
|
+
do*, the source is asked what must hold first — default-off, licence tier, version
|
|
129
|
+
floor, dependency — because "yes, supported" without the gate is a plan that fails on
|
|
130
|
+
site. The rule stays *ask*, never *produce*.
|
|
131
|
+
- **`anchor <path> <phrase>`** turns a quoted phrase into a verified locator, matching
|
|
132
|
+
whitespace as `\s+` because a PDF extraction breaks phrases mid-line — the gap that
|
|
133
|
+
cost a field user two generation rounds.
|
|
134
|
+
- **Extraction-as-artifact** for large binary corpora: the extraction is the artifact,
|
|
135
|
+
the digest moves onto the bytes locators actually address, and the original stays where
|
|
136
|
+
it lives as `original_path:` + `original_sha256:` (recorded, never checked — the limit
|
|
137
|
+
is stated wherever the fields are).
|
|
138
|
+
- **`--help` lists the overlay commands**, so the ones this lens adds are discoverable
|
|
139
|
+
from the CLI rather than only from the documentation.
|
|
140
|
+
|
|
5
141
|
## [1.0.1] - 2026-08-02
|
|
6
142
|
|
|
7
143
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,86 +1,89 @@
|
|
|
1
|
-
# KB Agentic Skill for Claude Code, Gemini CLI, Google Antigravity & Codex
|
|
2
|
-
|
|
3
|
-
`kb-agentic` turns an AI agent into the keeper of a second brain built **on the documents you supply** — not on what the model remembers. It supports Claude Code, Codex, Gemini CLI, Google Antigravity 2.0, Cursor/Windsurf-style project instructions, and optional devPNT governance.
|
|
4
|
-
|
|
5
|
-
The sibling of [`@antoneeo/agentic-sdlc-skill`](https://www.npmjs.com/package/@antoneeo/agentic-sdlc-skill), transplanted from source code to knowledge: same process, one different fidelity discipline.
|
|
6
|
-
|
|
7
|
-
## What it does
|
|
8
|
-
|
|
9
|
-
Two axes are kept apart on purpose — **abstraction** (a topic made of topics: the graph edges) and **certainty** (how strongly the corpus supports a statement: the claim rows).
|
|
10
|
-
|
|
11
|
-
1. **Intake — the source becomes untouchable.** Every document enters verbatim under `corpus/given/`, content-addressed by a raw-byte digest. A new version is appended with `supersedes:`, never overwritten. Non-text files get a **stored canonical extraction** (`.txt`, registered extractor) — those are the bytes locators point at, so an id stays valid across re-ingestion. On a large binary corpus the extraction **is** the artifact: the digest moves onto it (immutability still enforced, on the bytes that actually matter) and the original stays where it lives, recorded as `original_path:` + `original_sha256:`. 233 MB of manuals do not enter your repository to protect bytes no locator addresses — and the two recorded fields are honest about their limit: they let a human re-verify, they check nothing on their own. Spoken input is a note with `origin: elicited`; a synthesis carries `derived_from:`; your decision carries `basis:`. A note with none of the three is model knowledge dressed as a source, and the validator rejects it.
|
|
12
|
-
|
|
13
|
-
2. **Extraction — the unit is the claim.** Rows of `id | claim | valid | qty | about | source | prov | state`. The **id hashes the location and the quantity, never the text**, so an LLM rephrasing mints no new identity. The locator (`p=17@412-509`) is verified: the validator opens the extraction and checks the span exists — and `anchor <path> <phrase>` produces it for you, matching spaces as `\s+` because a PDF extraction breaks phrases mid-line. **Gates are extracted alongside powers**: for every row saying what something can do, the source is asked what must hold first — default-off, licence tier, version floor, dependency — because "yes, supported" without the gate is a plan that fails on site. The rule is *ask*, never *produce*: a source that states no gate yields no row. Validity scopes are half-open ("until March" and "from March" do not conflict). Quantities are typed — mixed kinds or currencies **refuse to sum**. What the source does not assert becomes a `gaps:` line, never a claim.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
The
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
1
|
+
# KB Agentic Skill for Claude Code, Gemini CLI, Google Antigravity & Codex
|
|
2
|
+
|
|
3
|
+
`kb-agentic` turns an AI agent into the keeper of a second brain built **on the documents you supply** — not on what the model remembers. It supports Claude Code, Codex, Gemini CLI, Google Antigravity 2.0, Cursor/Windsurf-style project instructions, and optional devPNT governance.
|
|
4
|
+
|
|
5
|
+
The sibling of [`@antoneeo/agentic-sdlc-skill`](https://www.npmjs.com/package/@antoneeo/agentic-sdlc-skill), transplanted from source code to knowledge: same process, one different fidelity discipline.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
Two axes are kept apart on purpose — **abstraction** (a topic made of topics: the graph edges) and **certainty** (how strongly the corpus supports a statement: the claim rows).
|
|
10
|
+
|
|
11
|
+
1. **Intake — the source becomes untouchable.** Every document enters verbatim under `corpus/given/`, content-addressed by a raw-byte digest. A new version is appended with `supersedes:`, never overwritten. Non-text files get a **stored canonical extraction** (`.txt`, registered extractor) — those are the bytes locators point at, so an id stays valid across re-ingestion. On a large binary corpus the extraction **is** the artifact: the digest moves onto it (immutability still enforced, on the bytes that actually matter) and the original stays where it lives, recorded as `original_path:` + `original_sha256:`. 233 MB of manuals do not enter your repository to protect bytes no locator addresses — and the two recorded fields are honest about their limit: they let a human re-verify, they check nothing on their own. Spoken input is a note with `origin: elicited`; a synthesis carries `derived_from:`; your decision carries `basis:`. A note with none of the three is model knowledge dressed as a source, and the validator rejects it.
|
|
12
|
+
|
|
13
|
+
2. **Extraction — the unit is the claim.** Rows of `id | claim | valid | qty | about | source | prov | state`. The **id hashes the location and the quantity, never the text**, so an LLM rephrasing mints no new identity. The locator (`p=17@412-509`) is verified: the validator opens the extraction and checks the span exists — and `anchor <path> <phrase>` produces it for you, matching spaces as `\s+` because a PDF extraction breaks phrases mid-line. **Gates are extracted alongside powers**: for every row saying what something can do, the source is asked what must hold first — default-off, licence tier, version floor, dependency — because "yes, supported" without the gate is a plan that fails on site. The rule is *ask*, never *produce*: a source that states no gate yields no row. Validity scopes are half-open ("until March" and "from March" do not conflict). Quantities are typed — mixed kinds or currencies **refuse to sum**. What the source does not assert becomes a `gaps:` line, never a claim.
|
|
14
|
+
|
|
15
|
+
**The source is exhausted, not sampled.** "Invents nothing" is a floor, and an extractor that stops when nothing it wrote is false stops on page twenty of a two-hundred-page manual with every row correct — which is exactly what a 200-page manual produces in the field. So a long source is read in **bounded windows** (30 pages by default, one plan task each, the ledger holding your place across sessions), and every window closes by advancing `extracted_through:` on the artifact's sidecar. That field is what makes "I am finished" falsifiable: claims with no coverage recorded are an error, a claim addressing a page past the declared coverage is a contradiction, and coverage short of the end is reported until it reaches it. Its limit is stated where it is written — **nothing proves a page was read**; what changes is that the shortcut must now be written down to pass. And exhaustive means read, never *a row per page*: a page that asserts nothing yields nothing.
|
|
16
|
+
|
|
17
|
+
3. **Placement — five verdicts, after querying the graph.** Descent through the generated index following every parent (polyhierarchy). EXISTS → reconcile; INADEQUATE → child; **MISSING only after the graph was actually asked**; GENERALIZES → escalate (a new root stops at you); UNPLACED → quarantine. Similar-but-maybe-different becomes a sibling **with the distinguishing line written** — if you cannot write it, it is the same concept. Cycles are refused at write time; merged nodes leave a tombstone with `redirect_to:`, never a deletion.
|
|
18
|
+
|
|
19
|
+
4. **Reconciliation — the machine detects and holds, it never decides.** Five outcomes: new / confirmation (the source is appended to the row — the base strengthens, it does not lengthen) / refinement (the old row goes `SUPERSEDED`, its text intact) / coexistence (disjoint scopes) / conflict → the whole set goes `CONTESTED`, **symmetrically**: flipping one cell by hand fails the check. Only new information resolves it — a later source, or **your ruling with a `basis:`**, the fact you know and the corpus does not. No basis, no ruling: a preference is not a fact. A ruling is challengeable — a later document reopens the case with your basis beside it.
|
|
20
|
+
|
|
21
|
+
5. **Escalation in one batch at the end of the run**, in legal form (the claims, the reopenable sources, the dates, why the machine cannot decide). Ingestion never stops to interrogate you.
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
## Key features
|
|
26
|
+
|
|
27
|
+
- **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.
|
|
28
|
+
- **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.
|
|
29
|
+
- **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.
|
|
30
|
+
- **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.
|
|
31
|
+
- **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".
|
|
32
|
+
- **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.
|
|
33
|
+
- **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.
|
|
34
|
+
- **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.
|
|
35
|
+
- **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).
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install -g @antoneeo/kb-agentic-skill@latest
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That is enough — the package's `postinstall` runs the installer. If your npm blocks
|
|
44
|
+
install scripts (`--ignore-scripts`, some CI/pnpm setups), run it by hand:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
kb-agentic-install-skill
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
> The command is on your PATH only after a **global** (`-g`) install; after a local
|
|
51
|
+
> `npm i`, invoke it as `npx kb-agentic-install-skill`.
|
|
52
|
+
|
|
53
|
+
The installer copies `skills/kb-agentic-skill/` recursively into native skill locations:
|
|
54
|
+
|
|
55
|
+
- Claude Code: `~/.claude/skills/kb-agentic/`
|
|
56
|
+
- Codex: `~/.codex/skills/kb-agentic/`
|
|
57
|
+
- Gemini CLI: `~/.gemini/skills/kb-agentic/`
|
|
58
|
+
- Google Antigravity: `~/.gemini/config/skills/kb-agentic/` (override the home with `ANTIGRAVITY_HOME`)
|
|
59
|
+
|
|
60
|
+
Restart the relevant agent, or reload skills where the CLI supports it.
|
|
61
|
+
|
|
62
|
+
Initialize a project:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
kb-agentic-init
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Run it inside a project to create `ai_docs/`, Vision documents, strategic docs, audit plan, and agent protocol files (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.cursorrules`).
|
|
69
|
+
|
|
70
|
+
## The family: three lenses, one spine
|
|
71
|
+
|
|
72
|
+
| Package | Faithful to | Unit of work |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| [`@antoneeo/agentic-sdlc-skill`](https://www.npmjs.com/package/@antoneeo/agentic-sdlc-skill) | this repository's code | feature |
|
|
75
|
+
| `@antoneeo/kb-agentic-skill` (this one) | the documents you supply | topic |
|
|
76
|
+
| [`@antoneeo/mkt-agentic-sdlc-skill`](https://www.npmjs.com/package/@antoneeo/mkt-agentic-sdlc-skill) | market evidence | engagement |
|
|
77
|
+
|
|
78
|
+
Triage, the Vision Gate, the review gates, the guide router, question discipline and the validator spine are byte-identical across the three. When two live in the same project, `routing.md` decides which lens owns a given piece of work, and any of the three validators gives the same verdict on the same tree.
|
|
79
|
+
|
|
80
|
+
## Standalone vs Hybrid
|
|
81
|
+
|
|
82
|
+
- **Standalone** — `ai_docs/` is the source of truth: vision, topics, corpus, audit, handoff.
|
|
83
|
+
- **Hybrid with devPNT** — devPNT governs `M-VISION`, Master Plan, Action Plan and versioned artifacts; `ai_docs/` stays as readable context, fallback and shadow. Divergence between your request, the local Vision and the M-VISION is surfaced before any work.
|
|
84
|
+
|
|
85
|
+
## Created By
|
|
86
|
+
|
|
87
|
+
Created by **Antonio Pinto** ([GitHub](https://github.com/Antoneeo)).
|
|
88
|
+
|
|
89
|
+
MIT (c) 2026 Antonio Pinto.
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kb-agentic-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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.4.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
|
@@ -105,6 +105,34 @@ const writeIfNotExists = (relPath, content, description) => {
|
|
|
105
105
|
|
|
106
106
|
seedFiles.forEach(([relPath, content]) => writeIfNotExists(relPath, content));
|
|
107
107
|
|
|
108
|
+
const EOL = String.fromCharCode(10);
|
|
109
|
+
// 5b. Merge hygiene for the append-only review log (F-028, defence in depth).
|
|
110
|
+
// `merge=union` is a BUILT-IN driver: no per-clone `git config`, unlike
|
|
111
|
+
// `merge=ours`, which silently does nothing until every clone configures it.
|
|
112
|
+
// Rows are date-stamped, so interleaving them loses no information and their
|
|
113
|
+
// order carries none. Create-only, and appended rather than rewritten: a user's
|
|
114
|
+
// own .gitattributes is never clobbered.
|
|
115
|
+
const gitattributes = () => {
|
|
116
|
+
const marker = 'ai_docs/audit/reviews/REVIEW_LOG.md';
|
|
117
|
+
const stanza = [
|
|
118
|
+
'',
|
|
119
|
+
'# agentic-sdlc: the review log is append-only and its rows are date-stamped,',
|
|
120
|
+
'# so a union merge keeps both sides instead of asking a human to pick one.',
|
|
121
|
+
marker + ' merge=union',
|
|
122
|
+
'',
|
|
123
|
+
].join(EOL);
|
|
124
|
+
const filePath = path.join(cwd, '.gitattributes');
|
|
125
|
+
if (!fs.existsSync(path.join(cwd, '.git'))) return; // not a git repo: nothing to do
|
|
126
|
+
const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
127
|
+
if (current.includes(marker)) {
|
|
128
|
+
console.log('SKIP .gitattributes already covers the review log.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
fs.writeFileSync(filePath, current + stanza, 'utf8');
|
|
132
|
+
console.log('OK .gitattributes: review log set to merge=union (concurrent reviews merge cleanly).');
|
|
133
|
+
};
|
|
134
|
+
gitattributes();
|
|
135
|
+
|
|
108
136
|
// 6. Client discovery and protocol pointers
|
|
109
137
|
console.log('\n--- Environment Analysis ---');
|
|
110
138
|
|
|
@@ -13,7 +13,7 @@ This skill guides knowledge management and documentation with a Document-First p
|
|
|
13
13
|
|
|
14
14
|
Support files in the skill directory:
|
|
15
15
|
- `templates.md`: templates for Vision, Knowledge ANALYSIS, Research SPIKE, SOP GUIDE, audit plan, and handoff.
|
|
16
|
-
- `portability.md`: carrying knowledge between projects — what a bundle is, why export is a closure rather than a selection, and the rule that knowledge crosses a project boundary while authority does not (`prov: IMPORTED`). Read before `export`/`import`.
|
|
16
|
+
- `portability.md`: carrying knowledge between projects — what a bundle is, why export is a closure rather than a selection, and the rule that knowledge crosses a project boundary while authority does not (`prov: IMPORTED`). Read before `export`/`import`.
|
|
17
17
|
- `taxonomy.md`: placing a claim in the topic graph — descent over the generated index, the five verdicts (EXISTS / INADEQUATE / MISSING / GENERALIZES / UNPLACED), the sibling rule, guarded re-parenting, canonicalization. Run at L3 before drafting.
|
|
18
18
|
- `guides.md`: pipeline for distilling user-provided indications into `ai_docs/reference/GUIDE_[topic].md`.
|
|
19
19
|
- `vision.md`: how to write a Vision a cold reviewer can actually apply — the properties that make a rule hold, the minimum operable sections, and the blind check.
|
|
@@ -74,8 +74,10 @@ Triage decides IF documentation is due; this table decides WHICH document each e
|
|
|
74
74
|
| `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 |
|
|
75
75
|
| `solutions/SPIKE_[topic].md` | Closing any Spike — including a negative outcome. | — |
|
|
76
76
|
| `vision/features/VISION_[feature].md` | Multi-milestone topic at analysis time, or retroactive trigger (second ANALYSIS on same theme). | 3 |
|
|
77
|
-
| `audit/handoff.md` (workstream registry) |
|
|
78
|
-
| `audit/HANDOFF_[topic].md` |
|
|
77
|
+
| `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 |
|
|
78
|
+
| `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 |
|
|
79
|
+
| `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 |
|
|
80
|
+
| `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 |
|
|
79
81
|
| `audit/audit_plan.md` (Standalone) | Bootstrap, and whenever a mapped area changes state (`sdlc_check.py mark`). | 1 |
|
|
80
82
|
| `reference/GUIDE_[topic].md` (`source_kind: document`) | Origin+purpose test (`guides.md`), or a proactive proposal accepted by user. | 4 / 5 |
|
|
81
83
|
| `reference/GUIDE_[topic].md` (`source_kind: code`/`domain`) | Recognized high-complexity domain/concept with no CURRENT guide $\rightarrow$ duty to write autonomously. | 4 / 5 |
|
|
@@ -84,9 +86,11 @@ Triage decides IF documentation is due; this table decides WHICH document each e
|
|
|
84
86
|
| `strategic/architecture.md`, `strategic/existing_features.md` | Bootstrap; update at closure when the knowledge catalog actually changed. | 1 / 5 |
|
|
85
87
|
| `vision/project_vision.md`, `roadmap.md`, `principles.md` | Bootstrap as `Status: DRAFT`; promoted to APPROVED only by explicit user confirmation. | 1 / 2 |
|
|
86
88
|
| `topics/<slug>.md` | A placement verdict creates it (MISSING/INADEQUATE-child, `taxonomy.md`); reconciliation updates its claim rows. One node per topic — a similar-but-distinct concept is a sibling with `related:` + a written distinction, never a merge and never a duplicate. Merged/renamed nodes become tombstones (`status: SUPERSEDED` + `redirect_to:`), never deleted. | 4 |
|
|
87
|
-
| a KB bundle (`export`) | Knowledge must leave this project. Export is L1 — it writes nothing into the corpus. **Importing one is L3**: a source enters the corpus and nodes are created, and a bundle is external input, so never L1 whatever its size (`portability.md`). | — |
|
|
89
|
+
| a KB bundle (`export`) | Knowledge must leave this project. Export is L1 — it writes nothing into the corpus. **Importing one is L3**: a source enters the corpus and nodes are created, and a bundle is external input, so never L1 whatever its size (`portability.md`). | — |
|
|
88
90
|
| `corpus/given/*` + sidecar | A source arrives: it becomes a content-addressed artifact with a sidecar carrying digest/date/`supersedes:`. A text source is copied verbatim. A non-text source yields its stored canonical extraction — copied **beside** the original when that is small enough to keep, or **instead of it** on a large binary corpus, where the original stays where it lives and is recorded as `original_path:`/`original_sha256:` (`distillation.md` §1, which owns this rule). Never edited after ingest — the digest check on whatever `given/` holds is what enforces it. | 4 |
|
|
89
91
|
| `corpus/notes/*` | Something is said (`origin: elicited`), synthesised (`derived_from:`), or ruled (`basis:`). A note with none of the three is refused by the validator. | 4 / 5 |
|
|
92
|
+
| `solutions/PLAN_[topic].md` + its ledger | A source too long for one context is ingested: **one task per reading window** (30 pages by default; the plan states the window used), each ending at a declared `extracted_through:`. That ledger is the register the ingestion resumes from across sessions — ingestion never builds a second one (`distillation.md` §3, `templates.md`). | 4 |
|
|
93
|
+
| `corpus/given/*.meta.md` — `extracted_through:` | Every reading window closes by advancing it (`p=<n>`, `L<n>`, `complete`). Required once any claim cites the artifact: unstated, "I am finished" cannot be falsified, which is how a sampled 200-page manual passes for an ingested one. | 4 |
|
|
90
94
|
| `INDEX.md`, `reference/INDEX.md`, `topics/INDEX.md`, `corpus/INDEX.md` | Regenerated by `sdlc_check.py index` at closure — never by hand; `validate` fails on a hand-edited one. | 5 |
|
|
91
95
|
|
|
92
96
|
## Operating Modes
|
|
@@ -6,6 +6,19 @@ of knowledge get its file".
|
|
|
6
6
|
**Does not answer**: where a claim's concept lives (above — `taxonomy.md`) or what
|
|
7
7
|
happens when it disagrees with an existing one (`reconciliation.md`).
|
|
8
8
|
|
|
9
|
+
> **North star.** *Not one assertion the source makes may be lost, and not one it does
|
|
10
|
+
> not make may appear.* These are one rule, not two: a ledger that invents nothing but
|
|
11
|
+
> keeps a tenth of the manual is as useless as one that keeps everything and made half
|
|
12
|
+
> of it up.
|
|
13
|
+
>
|
|
14
|
+
> The unit is the **assertion**, not the byte: layout, ordering, repetition and page
|
|
15
|
+
> furniture are not assertions. A page that asserts nothing yields nothing — exhaustive
|
|
16
|
+
> means **read**, never *a row per page*. And "I am finished" is an assertion like any
|
|
17
|
+
> other: `extracted_through:` is what makes it falsifiable.
|
|
18
|
+
|
|
19
|
+
Everything below is that one rule made operable. Where a rule below does not reach the
|
|
20
|
+
case in front of you, decide by the north star — both halves of it, in the same breath.
|
|
21
|
+
|
|
9
22
|
## 1. Intake — everything becomes a file first
|
|
10
23
|
|
|
11
24
|
Ingest **never touches the graph**. First the source enters the corpus; the graph is fed
|
|
@@ -19,8 +32,9 @@ from the corpus, so everything is re-derivable when the rules improve.
|
|
|
19
32
|
| an agent synthesis | a note in `corpus/notes/` with `derived_from:` listing its sources — a note with neither `origin:` nor `derived_from:` nor `basis:` is **model knowledge disguised as a source**, and the validator refuses it |
|
|
20
33
|
| a practitioner ruling | a note with `basis:` (`reconciliation.md`) |
|
|
21
34
|
|
|
22
|
-
Every sidecar (`<artifact>.meta.md`) carries: the digest, the date, provenance,
|
|
23
|
-
`supersedes:` when it replaces an earlier version
|
|
35
|
+
Every sidecar (`<artifact>.meta.md`) carries: the digest, the date, provenance,
|
|
36
|
+
`supersedes:` when it replaces an earlier version, and `extracted_through:` once anything
|
|
37
|
+
has been extracted from it (§3). `corpus/INDEX.md` is generated.
|
|
24
38
|
|
|
25
39
|
**Extraction-as-artifact — the variant for a large binary corpus.** Copying gigabytes
|
|
26
40
|
of PDFs into the docs root buys nothing: what the digest protects is *the bytes a
|
|
@@ -81,6 +95,33 @@ the offset span it came from. The extractor **invents nothing**: no labels, no
|
|
|
81
95
|
summaries-as-claims, no filling of gaps from model knowledge. What the source does not
|
|
82
96
|
assert does not become a row — it may become a `gaps:` entry on the topic.
|
|
83
97
|
|
|
98
|
+
**Exhaust the source; never sample it.** *Invents nothing* is a floor, and a floor is not
|
|
99
|
+
a target: an extractor that stops the moment nothing it wrote is false stops on page
|
|
100
|
+
twenty of a two-hundred-page manual, with every row it emitted correct. A source is
|
|
101
|
+
finished when every page has been **read**, not when enough rows exist. The claim count
|
|
102
|
+
settles nothing in either direction — a short source legitimately yields few rows and a
|
|
103
|
+
dense one yields many — so it can never distinguish *finished* from *sampled*.
|
|
104
|
+
|
|
105
|
+
**Read in a bounded window, and let the plan hold your place.** A long source does not fit
|
|
106
|
+
in one context, and an agent that runs out of room has two moves: summarize, or stop
|
|
107
|
+
silently. It summarizes. So read a fixed span, emit that span's rows, and only then move
|
|
108
|
+
on. **30 pages is the default and the plan states the window actually used** — page
|
|
109
|
+
density and context budgets differ per source and per model, so the number is declared
|
|
110
|
+
per ingestion rather than assumed. The window is also the unit of resumption: one plan
|
|
111
|
+
task per window (below), so a session that ends mid-source resumes at the next task
|
|
112
|
+
instead of re-reading or guessing where it stopped.
|
|
113
|
+
|
|
114
|
+
**Close each window by advancing `extracted_through:`** on the artifact's sidecar
|
|
115
|
+
(`templates.md`) — `p=<n>`, `L<n>`, or `complete` when the last page is read. It is what
|
|
116
|
+
makes "I am finished" falsifiable, and it makes the rows and the field check each other:
|
|
117
|
+
a claim whose locator addresses past the declared coverage is a contradiction, and the
|
|
118
|
+
validator reports it. Say the limit out loud, as with `original_sha256` above:
|
|
119
|
+
**nothing here proves a page was read.** A field advanced without extracting is not
|
|
120
|
+
mechanically detectable, precisely because a page that asserts nothing legitimately
|
|
121
|
+
yields no rows — that direction is verified at the ingestion review (`review.md`), like
|
|
122
|
+
the gates below. What the field buys is that the shortcut must now be **written down** to
|
|
123
|
+
pass, and a written claim can be reopened by anyone who cares to.
|
|
124
|
+
|
|
84
125
|
**Cover the gates, not only the powers.** "One row per assertion" is obeyable and still
|
|
85
126
|
leaves the ledger optimistic: reading a capability paragraph, an extractor emits the
|
|
86
127
|
capability and moves on, because that is what the paragraph asserts. So for every row
|
|
@@ -110,10 +151,17 @@ locator without it is how two generation rounds get lost.
|
|
|
110
151
|
|
|
111
152
|
Extraction is read-only on the corpus and blind to the graph: rows go to placement
|
|
112
153
|
(`taxonomy.md`) afterwards. Ingesting a document set is **L3 by Rule Zero**; the
|
|
113
|
-
ingestion plan derives from the ANALYSIS Action Plan, one task per
|
|
114
|
-
|
|
154
|
+
ingestion plan derives from the ANALYSIS Action Plan, **one task per reading window** —
|
|
155
|
+
a source that fits one window is one task, a 200-page manual at 30 pages is seven — each
|
|
156
|
+
task's `verify` = "claim rows parse; every source resolves under the docs root; the
|
|
157
|
+
sidecar's `extracted_through:` reaches this window's last page". Serial in v1:
|
|
115
158
|
correctness first.
|
|
116
159
|
|
|
160
|
+
The plan's ledger already records `status: done` per task (`templates.md`), which is the
|
|
161
|
+
register that survives a session boundary — so ingestion **builds no second one**. What
|
|
162
|
+
has been covered is recorded (the sidecar, the ledger); what remains is derived from
|
|
163
|
+
them, never stored.
|
|
164
|
+
|
|
117
165
|
## 4. Signal discipline (what "distillation" still means)
|
|
118
166
|
|
|
119
167
|
- Contract-first for any prose you write around the rows (reader, action, payload).
|
|
@@ -92,6 +92,8 @@ DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
92
92
|
LOC_PAGE_RE = re.compile(r"^p=(\d+)@(\d+)-(\d+)$")
|
|
93
93
|
LOC_LINE_RE = re.compile(r"^L(\d+)-(\d+)$")
|
|
94
94
|
LOC_CELL_RE = re.compile(r"^Sheet[^!]+![A-Z]+\d+$")
|
|
95
|
+
# F-031. How far a source has been read, in the unit its locators address.
|
|
96
|
+
EXTRACTED_THROUGH_RE = re.compile(r"^(?:complete|p=(\d+)|L(\d+))$")
|
|
95
97
|
|
|
96
98
|
# Unit conventions, documented in templates.md. effort in person-days
|
|
97
99
|
# (8h day, 5d week, 21d month); duration in calendar days; cost within ONE
|
|
@@ -664,6 +666,64 @@ def kb_build_topic_index(root):
|
|
|
664
666
|
return "\n".join(lines) + "\n"
|
|
665
667
|
|
|
666
668
|
|
|
669
|
+
def kb_parse_extracted_through(value):
|
|
670
|
+
"""('complete', None) | ('p', n) | ('L', n), or None when the value is not a
|
|
671
|
+
coverage statement.
|
|
672
|
+
|
|
673
|
+
Fail-closed on purpose: a field whose whole job is to be checkable must be
|
|
674
|
+
checkable, so an unreadable value is an error rather than a silent pass."""
|
|
675
|
+
m = EXTRACTED_THROUGH_RE.match((value or "").strip())
|
|
676
|
+
if not m:
|
|
677
|
+
return None
|
|
678
|
+
if m.group(1):
|
|
679
|
+
return "p", int(m.group(1))
|
|
680
|
+
if m.group(2):
|
|
681
|
+
return "L", int(m.group(2))
|
|
682
|
+
return "complete", None
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def kb_extraction_extent(artifact, kind):
|
|
686
|
+
"""How far the stored bytes go, in the unit `kind` — or None when nothing
|
|
687
|
+
measurable is stored.
|
|
688
|
+
|
|
689
|
+
Opens exactly the file `kb_check_locator` opens for that locator form: pages
|
|
690
|
+
live in the stored extraction beside the original, lines in the artifact
|
|
691
|
+
itself. Coverage is therefore measured against the same bytes a locator
|
|
692
|
+
addresses, and a binary is never read as text (a .pdf with no extraction
|
|
693
|
+
beside it is simply unmeasurable — the p= branch needs the .txt)."""
|
|
694
|
+
if kind == "p":
|
|
695
|
+
ext = artifact if artifact.suffix == ".txt" else artifact.with_suffix(".txt")
|
|
696
|
+
if not ext.is_file():
|
|
697
|
+
return None
|
|
698
|
+
return len(sdlc_core.read_text(ext).split("\f"))
|
|
699
|
+
if kind == "L":
|
|
700
|
+
if not artifact.is_file():
|
|
701
|
+
return None
|
|
702
|
+
return sdlc_core.read_text(artifact).count("\n") + 1
|
|
703
|
+
return None
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def kb_coverage_cell(artifact, through):
|
|
707
|
+
"""The coverage fact for one corpus row.
|
|
708
|
+
|
|
709
|
+
EVERY artifact gets one, including the finished ones: printing only the
|
|
710
|
+
incomplete ones would turn this index into 'the set that is not current',
|
|
711
|
+
which the Vision refuses (r9). It is a fact on an existing row, never a
|
|
712
|
+
filter and never a sort key."""
|
|
713
|
+
if not (through or "").strip():
|
|
714
|
+
return "extraction not recorded"
|
|
715
|
+
parsed = kb_parse_extracted_through(through)
|
|
716
|
+
if parsed is None:
|
|
717
|
+
return "extracted through %s (unreadable)" % through.strip()
|
|
718
|
+
kind, n = parsed
|
|
719
|
+
if kind == "complete":
|
|
720
|
+
return "extracted through complete"
|
|
721
|
+
total = kb_extraction_extent(artifact, kind)
|
|
722
|
+
stated = ("p=%d" % n) if kind == "p" else ("L%d" % n)
|
|
723
|
+
return "extracted through %s of %d" % (stated, total) if total \
|
|
724
|
+
else "extracted through %s" % stated
|
|
725
|
+
|
|
726
|
+
|
|
667
727
|
def kb_build_corpus_index(root):
|
|
668
728
|
"""One row per corpus artifact, from sidecars and note frontmatter."""
|
|
669
729
|
corpus = root / "corpus"
|
|
@@ -676,9 +736,10 @@ def kb_build_corpus_index(root):
|
|
|
676
736
|
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
|
|
677
737
|
orig = meta_p.name[:-len(".meta.md")]
|
|
678
738
|
sup = (meta.get("supersedes") or "").strip()
|
|
679
|
-
lines.append("- `%s` — %s%s" % (
|
|
739
|
+
lines.append("- `%s` — %s%s — %s" % (
|
|
680
740
|
orig, (meta.get("date") or "undated"),
|
|
681
|
-
(" — supersedes `%s`" % sup) if sup else ""
|
|
741
|
+
(" — supersedes `%s`" % sup) if sup else "",
|
|
742
|
+
kb_coverage_cell(given / orig, meta.get("extracted_through"))))
|
|
682
743
|
notes = corpus / "notes"
|
|
683
744
|
if notes.is_dir():
|
|
684
745
|
lines.append("")
|
|
@@ -692,12 +753,125 @@ def kb_build_corpus_index(root):
|
|
|
692
753
|
return "\n".join(lines) + "\n"
|
|
693
754
|
|
|
694
755
|
|
|
756
|
+
def kb_cited_extents(root):
|
|
757
|
+
"""Per artifact file name, what the claim rows say about it: the highest page
|
|
758
|
+
and the highest line any locator addresses (with the row that says so), and
|
|
759
|
+
every row citing it. One walk of topics/, shared by the supersession and the
|
|
760
|
+
coverage checks — two walks of the same tree for two questions is how the
|
|
761
|
+
answers start disagreeing."""
|
|
762
|
+
cited = {}
|
|
763
|
+
topics = root / "topics"
|
|
764
|
+
if not topics.is_dir():
|
|
765
|
+
return cited
|
|
766
|
+
for p in sorted(topics.glob("*.md")):
|
|
767
|
+
for row in kb_parse_claims(sdlc_core.read_text(p))[0]:
|
|
768
|
+
where = "topics/%s:%d" % (p.name, row["_line"])
|
|
769
|
+
for src in row["source"].split(";"):
|
|
770
|
+
src = src.strip()
|
|
771
|
+
if not src:
|
|
772
|
+
continue
|
|
773
|
+
path_s, loc = src.rsplit("#", 1) if "#" in src else (src, "")
|
|
774
|
+
parts = Path(path_s.strip().replace("\\", "/")).parts
|
|
775
|
+
if parts[-3:-1] != ("corpus", "given"):
|
|
776
|
+
# Keyed by file name, so a note sharing a name with an
|
|
777
|
+
# artifact would otherwise be attributed to it and inflate
|
|
778
|
+
# its extents. Both consumers here ask only about given/.
|
|
779
|
+
continue
|
|
780
|
+
name = parts[-1]
|
|
781
|
+
e = cited.setdefault(name, {"p": 0, "p_where": None,
|
|
782
|
+
"L": 0, "L_where": None, "rows": []})
|
|
783
|
+
e["rows"].append(where)
|
|
784
|
+
m = LOC_PAGE_RE.match(loc.strip())
|
|
785
|
+
if m and int(m.group(1)) > e["p"]:
|
|
786
|
+
e["p"], e["p_where"] = int(m.group(1)), where
|
|
787
|
+
continue
|
|
788
|
+
m = LOC_LINE_RE.match(loc.strip())
|
|
789
|
+
if m and int(m.group(2)) > e["L"]:
|
|
790
|
+
e["L"], e["L_where"] = int(m.group(2)), where
|
|
791
|
+
return cited
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _kb_cited_for(cited, artifact_name):
|
|
795
|
+
"""Claims may cite the original or its stored extraction — both address the
|
|
796
|
+
same bytes, so both count as citing this artifact."""
|
|
797
|
+
names = [artifact_name]
|
|
798
|
+
if not artifact_name.endswith(".txt"):
|
|
799
|
+
names.append(Path(artifact_name).with_suffix(".txt").name)
|
|
800
|
+
found = [cited[n] for n in names if n in cited]
|
|
801
|
+
if not found:
|
|
802
|
+
return None
|
|
803
|
+
merged = dict(found[0])
|
|
804
|
+
for e in found[1:]:
|
|
805
|
+
for kind in ("p", "L"):
|
|
806
|
+
if e[kind] > merged[kind]:
|
|
807
|
+
merged[kind], merged[kind + "_where"] = e[kind], e[kind + "_where"]
|
|
808
|
+
merged["rows"] = merged["rows"] + e["rows"]
|
|
809
|
+
return merged
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def kb_check_coverage(rel, artifact, through, facts, errors, warnings):
|
|
813
|
+
"""`extracted_through:` against the rows and against the stored bytes (F-031).
|
|
814
|
+
|
|
815
|
+
Four outcomes, and the boundary between them is the whole point: claims with
|
|
816
|
+
no field errors (an unfalsifiable 'done'); a field that contradicts the bytes
|
|
817
|
+
or the rows errors; a field short of the end warns, because partial work is
|
|
818
|
+
legal mid-ingestion; an artifact nobody extracted from stays silent.
|
|
819
|
+
|
|
820
|
+
The limit, stated where the code is: nothing here proves a page was READ. A
|
|
821
|
+
field advanced without extracting is invisible to any checker, since a page
|
|
822
|
+
that asserts nothing legitimately yields no rows — that direction belongs to
|
|
823
|
+
the ingestion review. What this buys is that the shortcut must be written
|
|
824
|
+
down to pass."""
|
|
825
|
+
if not through:
|
|
826
|
+
if facts:
|
|
827
|
+
errors.append("%s: claims cite this artifact and the sidecar has no "
|
|
828
|
+
"'extracted_through:' — how far a source was read is an "
|
|
829
|
+
"assertion like any other, and unstated 'I am finished' "
|
|
830
|
+
"cannot be falsified. Record it: 'p=<n>', 'L<n>', or "
|
|
831
|
+
"'complete' (first row at %s)" % (rel, facts["rows"][0]))
|
|
832
|
+
return
|
|
833
|
+
parsed = kb_parse_extracted_through(through)
|
|
834
|
+
if parsed is None:
|
|
835
|
+
errors.append("%s: extracted_through: %r is not a coverage statement — use "
|
|
836
|
+
"'complete', 'p=<n>' or 'L<n>'" % (rel, through))
|
|
837
|
+
return
|
|
838
|
+
kind, n = parsed
|
|
839
|
+
if kind == "complete":
|
|
840
|
+
return
|
|
841
|
+
other = "L" if kind == "p" else "p"
|
|
842
|
+
unit = "pages" if kind == "p" else "lines"
|
|
843
|
+
total = kb_extraction_extent(artifact, kind)
|
|
844
|
+
if facts and facts[other] and not facts[kind]:
|
|
845
|
+
errors.append("%s: coverage is stated in %s while every claim addresses %s "
|
|
846
|
+
"(%s) — stated in the wrong unit it compares with nothing, "
|
|
847
|
+
"and nothing here is checkable"
|
|
848
|
+
% (rel, unit, "lines" if kind == "p" else "pages",
|
|
849
|
+
facts[other + "_where"]))
|
|
850
|
+
return
|
|
851
|
+
if total and n > total:
|
|
852
|
+
errors.append("%s: extracted_through: %s, past the end of the stored bytes "
|
|
853
|
+
"(%d %s) — coverage cannot exceed what was stored"
|
|
854
|
+
% (rel, through, total, unit))
|
|
855
|
+
elif total and n < total:
|
|
856
|
+
warnings.append("%s: extracted through %s of %d %s — ingestion is incomplete "
|
|
857
|
+
"(legal mid-work: a source is finished when every page has "
|
|
858
|
+
"been read, not when enough rows exist)"
|
|
859
|
+
% (rel, through, total, unit))
|
|
860
|
+
if facts and facts[kind] > n:
|
|
861
|
+
reached = ("p=%d" % facts[kind]) if kind == "p" else ("L%d" % facts[kind])
|
|
862
|
+
errors.append("%s: a claim addresses %s, past the declared coverage %s (%s) — "
|
|
863
|
+
"the sidecar and the rows contradict each other; one of the two "
|
|
864
|
+
"is wrong" % (rel, reached, through, facts[kind + "_where"]))
|
|
865
|
+
|
|
866
|
+
|
|
695
867
|
def kb_corpus_check(root):
|
|
696
|
-
"""Corpus integrity: digests, supersession, laundered notes.
|
|
868
|
+
"""Corpus integrity: digests, supersession, coverage, laundered notes.
|
|
869
|
+
Findings only."""
|
|
697
870
|
errors, warnings = [], []
|
|
698
871
|
corpus = root / "corpus"
|
|
699
872
|
if not corpus.is_dir():
|
|
700
873
|
return errors, warnings
|
|
874
|
+
cited = kb_cited_extents(root)
|
|
701
875
|
superseded = set()
|
|
702
876
|
given = corpus / "given"
|
|
703
877
|
if given.is_dir():
|
|
@@ -721,6 +895,9 @@ def kb_corpus_check(root):
|
|
|
721
895
|
if not (given / sup).is_file():
|
|
722
896
|
warnings.append("%s: supersedes %r which is not in given/"
|
|
723
897
|
% (rel, sup))
|
|
898
|
+
kb_check_coverage(rel, orig,
|
|
899
|
+
(meta.get("extracted_through") or "").strip(),
|
|
900
|
+
_kb_cited_for(cited, orig.name), errors, warnings)
|
|
724
901
|
notes = corpus / "notes"
|
|
725
902
|
if notes.is_dir():
|
|
726
903
|
for p in sorted(notes.glob("*.md")):
|
|
@@ -730,20 +907,11 @@ def kb_corpus_check(root):
|
|
|
730
907
|
errors.append("corpus/notes/%s: neither 'derived_from:' nor "
|
|
731
908
|
"'origin:' nor 'basis:' — model knowledge disguised "
|
|
732
909
|
"as a source" % p.name)
|
|
733
|
-
# claims resting on superseded originals (UC4)
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
rows, _ = kb_parse_claims(sdlc_core.read_text(p))
|
|
739
|
-
for row in rows:
|
|
740
|
-
for src in row["source"].split(";"):
|
|
741
|
-
name = Path(src.split("#")[0].strip()).name
|
|
742
|
-
if name in superseded:
|
|
743
|
-
warnings.append(
|
|
744
|
-
"topics/%s:%d: claim rests on %s, which a newer "
|
|
745
|
-
"version supersedes — re-verify or re-place"
|
|
746
|
-
% (p.name, row["_line"], name))
|
|
910
|
+
# claims resting on superseded originals (UC4), from the same single walk
|
|
911
|
+
for name in sorted(superseded):
|
|
912
|
+
for where in (cited.get(name) or {}).get("rows", []):
|
|
913
|
+
warnings.append("%s: claim rests on %s, which a newer version "
|
|
914
|
+
"supersedes — re-verify or re-place" % (where, name))
|
|
747
915
|
return errors, warnings
|
|
748
916
|
|
|
749
917
|
|
|
@@ -215,7 +215,11 @@ DEFAULT_DOMAIN = "code"
|
|
|
215
215
|
PORTABLE_CHECKS = {}
|
|
216
216
|
# Which check namespaces this distribution actually carries. Set by the entry point;
|
|
217
217
|
# a `checks:` entry outside it WARNS visibly rather than passing silently.
|
|
218
|
-
_ENTRY_POINT = {"domain": DEFAULT_DOMAIN, "provides": ()}
|
|
218
|
+
_ENTRY_POINT = {"domain": DEFAULT_DOMAIN, "provides": (), "script": "sdlc_check.py"}
|
|
219
|
+
# "script" is what generated headers tell the reader to RUN. Declared, never
|
|
220
|
+
# derived from sys.argv: the generated bytes must not depend on how the command
|
|
221
|
+
# was invoked, or the alignment check would fail on the invocation instead of on
|
|
222
|
+
# the content.
|
|
219
223
|
|
|
220
224
|
|
|
221
225
|
# --- distribution profile ----------------------------------------------------
|
|
@@ -292,10 +296,17 @@ def portable_check(name):
|
|
|
292
296
|
return register
|
|
293
297
|
|
|
294
298
|
|
|
295
|
-
def set_entry_point(domain, provides=()):
|
|
299
|
+
def set_entry_point(domain, provides=(), script=None):
|
|
296
300
|
"""Declare which domain this distribution is and which check namespaces it ships."""
|
|
297
301
|
_ENTRY_POINT["domain"] = domain
|
|
298
302
|
_ENTRY_POINT["provides"] = tuple(provides)
|
|
303
|
+
if script:
|
|
304
|
+
_ENTRY_POINT["script"] = script
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def entry_script():
|
|
308
|
+
"""The command name a generated header tells the reader to run."""
|
|
309
|
+
return _ENTRY_POINT["script"]
|
|
299
310
|
|
|
300
311
|
|
|
301
312
|
def project_default_domain(root):
|
|
@@ -727,6 +738,142 @@ def build_manifest(root):
|
|
|
727
738
|
return "\n".join(lines).rstrip() + "\n"
|
|
728
739
|
|
|
729
740
|
|
|
741
|
+
# ------------------------------------------------- workstream registry (F-028)
|
|
742
|
+
# audit/handoff.md is GENERATED from one source file per open workstream, so two
|
|
743
|
+
# writers working two workstreams touch two different files. Row-per-workstream
|
|
744
|
+
# alone was not enough -- F-019 had that and the file still conflicted twice,
|
|
745
|
+
# because a file-global `Date:` header defeats row-level ownership. The header is
|
|
746
|
+
# now DERIVED, so no writer touches it.
|
|
747
|
+
|
|
748
|
+
REGISTRY_COLUMNS = ("Workstream", "Level", "Branch", "Status", "Since",
|
|
749
|
+
"Next step", "Details")
|
|
750
|
+
REGISTRY_KEYS = ("workstream", "level", "branch", "status", "since", "next")
|
|
751
|
+
REGISTRY_CAP = 20 # a signal, never a truncation (see cmd_index)
|
|
752
|
+
PROJECT_NOTES = "project_notes.md" # NOT handoff_notes.md: the HANDOFF_*.md
|
|
753
|
+
# glob is case-insensitive on Windows, and that name would be collected as a
|
|
754
|
+
# source. The trap is real; the name is the fix.
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def registry_header():
|
|
758
|
+
return (f"<!-- GENERATED by {entry_script()} index - do not edit by hand. "
|
|
759
|
+
f"Source of truth: the HANDOFF_*.md files in {docs_dir()}/audit/. -->")
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def list_workstreams(root):
|
|
763
|
+
"""[(path, meta)] for audit/HANDOFF_*.md carrying registry frontmatter.
|
|
764
|
+
|
|
765
|
+
Opt-in by the presence of `workstream:` -- a project whose handoff is still
|
|
766
|
+
hand-written has no sources, so nothing generates and nothing errors (the
|
|
767
|
+
F-019 migration lesson). Sorted by workstream id: the alignment check is a
|
|
768
|
+
byte comparison, and glob order differs across filesystems."""
|
|
769
|
+
aud = ai_path(root, "audit")
|
|
770
|
+
out = []
|
|
771
|
+
if not aud.is_dir():
|
|
772
|
+
return out
|
|
773
|
+
for p in sorted(aud.glob("HANDOFF_*.md")):
|
|
774
|
+
meta = load_frontmatter(read_text(p).splitlines())
|
|
775
|
+
if str(meta.get("workstream") or "").strip():
|
|
776
|
+
out.append((p, meta))
|
|
777
|
+
out.sort(key=lambda pm: (str(pm[1].get("workstream")).strip().lower(), pm[0].name))
|
|
778
|
+
return out
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _registry_cell(value):
|
|
782
|
+
text = str(value if value is not None else "").strip()
|
|
783
|
+
return text.replace("|", "\\|") or "-"
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def build_registry(root):
|
|
787
|
+
"""The generated registry, or "" when there is no source to build it from.
|
|
788
|
+
|
|
789
|
+
Deterministic by construction: sorted rows, and a `Date:` taken from the
|
|
790
|
+
newest `updated:` VALUE written inside the sources -- never a filesystem
|
|
791
|
+
timestamp. Git does not preserve mtimes, so an mtime-derived header would
|
|
792
|
+
regenerate differently in every fresh clone and the alignment check would
|
|
793
|
+
fire on a tree nobody touched."""
|
|
794
|
+
rows = list_workstreams(root)
|
|
795
|
+
if not rows:
|
|
796
|
+
return ""
|
|
797
|
+
stamp = max(str(m.get("updated") or m.get("since") or "").strip() or "0000-00-00"
|
|
798
|
+
for _p, m in rows)
|
|
799
|
+
lines = ["# Handoff — workstream registry",
|
|
800
|
+
f"Date: {stamp} (UTC)",
|
|
801
|
+
"",
|
|
802
|
+
registry_header(),
|
|
803
|
+
"",
|
|
804
|
+
"| " + " | ".join(REGISTRY_COLUMNS) + " |",
|
|
805
|
+
"|" + "---|" * len(REGISTRY_COLUMNS)]
|
|
806
|
+
for p, meta in rows:
|
|
807
|
+
extra = str(meta.get("details") or "").strip()
|
|
808
|
+
details = f"{p.name} · {extra}" if extra else p.name
|
|
809
|
+
cells = [_registry_cell(meta.get(k)) for k in REGISTRY_KEYS]
|
|
810
|
+
lines.append("| " + " | ".join(cells) + f" | {_registry_cell(details)} |")
|
|
811
|
+
notes = ai_path(root, "audit", PROJECT_NOTES)
|
|
812
|
+
if notes.is_file():
|
|
813
|
+
body = read_text(notes).strip()
|
|
814
|
+
if body:
|
|
815
|
+
lines += ["", "## Project-wide notes", "", body]
|
|
816
|
+
return "\n".join(lines) + "\n"
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def parse_registry_rows(text):
|
|
820
|
+
"""Workstream ids in a registry table, hand-written or generated."""
|
|
821
|
+
ids = []
|
|
822
|
+
for line in text.splitlines():
|
|
823
|
+
s = line.strip()
|
|
824
|
+
if not s.startswith("|") or set(s) <= {"|", "-", " ", ":"}:
|
|
825
|
+
continue
|
|
826
|
+
first = s.strip("|").split("|")[0].strip()
|
|
827
|
+
if first and first.lower() != "workstream":
|
|
828
|
+
ids.append(first)
|
|
829
|
+
return ids
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def registry_conversion_blockers(root):
|
|
833
|
+
"""What stops `index` from writing over a hand-written handoff.md.
|
|
834
|
+
|
|
835
|
+
The mixed state is the trap this exists for: converting one row at a time
|
|
836
|
+
leaves a project at one source file and five hand-written rows, and
|
|
837
|
+
regenerating from the one source DELETES the other five -- silently, in the
|
|
838
|
+
file whose whole purpose is not losing them. So conversion is per project.
|
|
839
|
+
Empty list = writing is safe."""
|
|
840
|
+
hand = ai_path(root, "audit", "handoff.md")
|
|
841
|
+
if not hand.is_file():
|
|
842
|
+
return []
|
|
843
|
+
text = read_text(hand)
|
|
844
|
+
if "GENERATED by sdlc_check.py index" in text:
|
|
845
|
+
return [] # already ours
|
|
846
|
+
blockers = []
|
|
847
|
+
known = {str(m.get("workstream")).strip() for _p, m in list_workstreams(root)}
|
|
848
|
+
orphans = [r for r in parse_registry_rows(text) if r not in known]
|
|
849
|
+
if orphans:
|
|
850
|
+
blockers.append("rows no HANDOFF_*.md accounts for: " + ", ".join(orphans))
|
|
851
|
+
# Everything else in the file must have a home too, or it is lost on write:
|
|
852
|
+
# a pre-1.17 narrative handoff carries no table at all, so orphan rows alone
|
|
853
|
+
# would not notice it.
|
|
854
|
+
notes_ok = ai_path(root, "audit", PROJECT_NOTES).is_file()
|
|
855
|
+
leftovers, in_notes = [], False
|
|
856
|
+
for line in text.splitlines():
|
|
857
|
+
s = line.strip()
|
|
858
|
+
if not s or s.startswith("|") or s.startswith("<!--"):
|
|
859
|
+
continue
|
|
860
|
+
if s.startswith("# ") or re.match(r"^(?:Date|Data):", s):
|
|
861
|
+
continue
|
|
862
|
+
if re.match(r"^##\s+Project-wide notes\s*$", s):
|
|
863
|
+
in_notes = True
|
|
864
|
+
continue
|
|
865
|
+
if s.startswith("## "):
|
|
866
|
+
in_notes = False
|
|
867
|
+
if in_notes and notes_ok:
|
|
868
|
+
continue
|
|
869
|
+
leftovers.append(s)
|
|
870
|
+
if leftovers:
|
|
871
|
+
blockers.append("content outside the table with nowhere to go (%d line(s), first: %r) "
|
|
872
|
+
"-- project-wide notes belong in audit/%s"
|
|
873
|
+
% (len(leftovers), leftovers[0][:60], PROJECT_NOTES))
|
|
874
|
+
return blockers
|
|
875
|
+
|
|
876
|
+
|
|
730
877
|
def list_guides(root):
|
|
731
878
|
"""[(rel_to_ai_docs, path, meta, text)] for ai_docs/reference/GUIDE_*.md."""
|
|
732
879
|
ref = ai_path(root, "reference")
|
|
@@ -821,6 +968,30 @@ def cmd_index(root):
|
|
|
821
968
|
"a comprehension map (`source_kind: code`) -- see `guides.md`.\n",
|
|
822
969
|
encoding="utf-8")
|
|
823
970
|
print(f"[ok] guide router regenerated (empty stub): {gidx}")
|
|
971
|
+
return max(rc_registry(root), 0)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def rc_registry(root):
|
|
975
|
+
"""Write the generated workstream registry, or refuse and say why (F-028)."""
|
|
976
|
+
ws = list_workstreams(root)
|
|
977
|
+
if not ws:
|
|
978
|
+
return 0 # no sources: a hand-written handoff is untouched
|
|
979
|
+
blockers = registry_conversion_blockers(root)
|
|
980
|
+
hand = ai_path(root, "audit", "handoff.md")
|
|
981
|
+
if blockers:
|
|
982
|
+
print(f"[ERROR] {docs_dir()}/audit/handoff.md NOT regenerated -- it still holds "
|
|
983
|
+
"state no source accounts for:")
|
|
984
|
+
for b in blockers:
|
|
985
|
+
print(f" - {b}")
|
|
986
|
+
print(" Convert the whole registry at once (templates.md): converting "
|
|
987
|
+
"one row at a time is the state that loses the others.")
|
|
988
|
+
return 1
|
|
989
|
+
hand.parent.mkdir(parents=True, exist_ok=True)
|
|
990
|
+
hand.write_text(build_registry(root), encoding="utf-8")
|
|
991
|
+
print(f"[ok] workstream registry regenerated: {hand}")
|
|
992
|
+
if len(ws) > REGISTRY_CAP:
|
|
993
|
+
print(f"[warn] {len(ws)} open workstreams: the registry is meant to stay under "
|
|
994
|
+
f"{REGISTRY_CAP}. Nothing was truncated -- closing one is the fix.")
|
|
824
995
|
return 0
|
|
825
996
|
|
|
826
997
|
|
|
@@ -1257,8 +1428,33 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1257
1428
|
elif norm_text(read_text(gidx)) != norm_text(build_guide_index(root)):
|
|
1258
1429
|
errors.append(f"{docs_dir()}/reference/INDEX.md not aligned with the guides: run 'sdlc_check.py index'")
|
|
1259
1430
|
|
|
1260
|
-
# Handoff: header and freshness
|
|
1431
|
+
# Handoff: alignment with its sources (F-028), then header and freshness
|
|
1261
1432
|
hand = ai / "audit" / "handoff.md"
|
|
1433
|
+
workstreams = list_workstreams(root)
|
|
1434
|
+
if workstreams:
|
|
1435
|
+
if not hand.is_file():
|
|
1436
|
+
errors.append(f"{docs_dir()}/audit/handoff.md missing while HANDOFF_*.md sources "
|
|
1437
|
+
"exist: the registry is the only place a cold agent sees the open "
|
|
1438
|
+
"workstreams -- run 'sdlc_check.py index'")
|
|
1439
|
+
elif norm_text(read_text(hand)) != norm_text(build_registry(root)):
|
|
1440
|
+
errors.append(f"{docs_dir()}/audit/handoff.md not aligned with its HANDOFF_*.md "
|
|
1441
|
+
"sources: run 'sdlc_check.py index'. A merge resolved by hand is "
|
|
1442
|
+
"exactly what this catches")
|
|
1443
|
+
if len(workstreams) > REGISTRY_CAP:
|
|
1444
|
+
warnings.append(f"{len(workstreams)} open workstreams: the registry is meant to "
|
|
1445
|
+
f"stay under {REGISTRY_CAP}")
|
|
1446
|
+
# Two files claiming one workstream is the collision this design does NOT
|
|
1447
|
+
# fix (two people opening the same work under different file names). It
|
|
1448
|
+
# would otherwise show up as two identical-looking rows and nothing else.
|
|
1449
|
+
seen = {}
|
|
1450
|
+
for p, meta in workstreams:
|
|
1451
|
+
wid = str(meta.get("workstream")).strip()
|
|
1452
|
+
if wid in seen:
|
|
1453
|
+
warnings.append(f"{docs_dir()}/audit/{p.name} and {seen[wid]} both claim "
|
|
1454
|
+
f"workstream '{wid}': the registry shows two rows for one "
|
|
1455
|
+
"workstream — decide which file owns it")
|
|
1456
|
+
else:
|
|
1457
|
+
seen[wid] = p.name
|
|
1262
1458
|
if hand.is_file():
|
|
1263
1459
|
m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
|
|
1264
1460
|
if not m:
|
|
@@ -83,7 +83,7 @@ Must-reads for this project, in order. The full manifest of canonical docs is
|
|
|
83
83
|
1. `reference/INDEX.md` — the guide router: which guide already governs the work you are about to do (generated).
|
|
84
84
|
2. `vision/project_vision.md` — why the project exists (check its Status first).
|
|
85
85
|
3. `strategic/architecture.md` — how it is built.
|
|
86
|
-
4. `audit/handoff.md` — where work stopped last session (
|
|
86
|
+
4. `audit/handoff.md` — where work stopped last session (generated from the `HANDOFF_*.md` beside it; never edited by hand).
|
|
87
87
|
|
|
88
88
|
Directory purposes: `vision/` (project direction), `strategic/` (architecture and
|
|
89
89
|
feature catalog), `reference/` (operative guides), `solutions/` (per-feature
|
|
@@ -355,6 +355,25 @@ owned, validator-read-only): `{ "<task_id>": {"status": "done", "verify_result":
|
|
|
355
355
|
sentinel skips re-dispatch; any other value (or a missing `status`) is treated
|
|
356
356
|
as pending. A ledger id absent from the plan is a non-fatal orphan warning.
|
|
357
357
|
|
|
358
|
+
**Ingestion plans: one task per reading window** (`distillation.md` §3). A source too
|
|
359
|
+
long for one context becomes several tasks over the same artifact, each ending at a
|
|
360
|
+
declared page:
|
|
361
|
+
|
|
362
|
+
```json
|
|
363
|
+
{
|
|
364
|
+
"id": "T2",
|
|
365
|
+
"title": "Extract manual-1a2b3c4d.pdf, pages 31-60",
|
|
366
|
+
"paths": ["ai_docs/topics/pricing.md",
|
|
367
|
+
"ai_docs/corpus/given/manual-1a2b3c4d.pdf.meta.md"],
|
|
368
|
+
"produces": ["ai_docs/corpus/given/manual-1a2b3c4d.pdf.meta.md#extracted_through=p=60"],
|
|
369
|
+
"verify": "python <skill_dir>/scripts/sdlc_check.py check"
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
That ledger **is** the register an ingestion resumes from across sessions, so ingestion
|
|
374
|
+
adds no second one: what has been covered is recorded (here and on the sidecar), and what
|
|
375
|
+
remains is derived — the next window is the next pending task.
|
|
376
|
+
|
|
358
377
|
## ai_docs/audit/audit_plan.md (Standalone mode only)
|
|
359
378
|
|
|
360
379
|
The `Reference` field (git hash or ISO UTC timestamp) is managed by `sdlc_check.py mark` — do not fill it by hand. Freshness is verified with `sdlc_check.py stale`.
|
|
@@ -370,36 +389,50 @@ States: PENDING (to analyze) | ANALYZED (analyzed, with reference) | SKIPPED (wi
|
|
|
370
389
|
| vendor/ | SKIPPED | - | vendored code |
|
|
371
390
|
```
|
|
372
391
|
|
|
373
|
-
## ai_docs/audit/handoff.md — the workstream registry
|
|
374
|
-
|
|
375
|
-
One row per OPEN workstream
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
392
|
+
## ai_docs/audit/handoff.md — the workstream registry (GENERATED)
|
|
393
|
+
|
|
394
|
+
One row per OPEN workstream. **Never written by hand**: `sdlc_check.py index` builds
|
|
395
|
+
it from the `HANDOFF_[topic].md` files, and `validate` errors when the two disagree.
|
|
396
|
+
It is an **inventory for lookup** (like the generated manifest), not a work board: no
|
|
397
|
+
assignment, no due dates, no execution ordering, no holder.
|
|
398
|
+
|
|
399
|
+
**Why generated, and not just one row per workstream.** Row-per-workstream alone was
|
|
400
|
+
tried and was not enough: two workstreams opened from one base still conflicted twice
|
|
401
|
+
in this file, because a file-global `Date:` header defeats row-level ownership no
|
|
402
|
+
matter how few rows each writer touches. So the truth moved into the per-workstream
|
|
403
|
+
file, and the header is derived (the newest `updated:` in the sources — a value, never
|
|
404
|
+
a filesystem timestamp, which git does not preserve). Two writers on two workstreams
|
|
405
|
+
now touch two different files. The generated view can still conflict at merge; that
|
|
406
|
+
conflict is resolved **mechanically** by re-running `index`, never by hand, and
|
|
407
|
+
`validate` refuses CLEAN until the file matches its sources.
|
|
408
|
+
|
|
409
|
+
Project-wide notes have their own source, `ai_docs/audit/project_notes.md`, appended
|
|
410
|
+
verbatim under `## Project-wide notes`. (Not `handoff_notes.md`: the `HANDOFF_*.md`
|
|
411
|
+
glob is case-insensitive on Windows and would collect it as a workstream.)
|
|
412
|
+
|
|
413
|
+
**Converting an existing project** — lazily, at the first write, and **all at once**.
|
|
414
|
+
Converting one row at a time is the state that loses the others: the next `index`
|
|
415
|
+
would regenerate from the one source and drop the rest. `index` refuses to write while
|
|
416
|
+
anything in the file is unaccounted for, and names it. A pre-1.17 narrative handoff
|
|
417
|
+
(`## Active features` / `## Next step` / `## Session notes`) is the same conversion:
|
|
418
|
+
each bullet becomes a `HANDOFF_[topic].md`, `## Session notes` becomes
|
|
419
|
+
`project_notes.md`. A project with no sources yet is not touched and reports nothing —
|
|
420
|
+
migrating a repository nobody is working on buys nothing.
|
|
391
421
|
|
|
392
422
|
```markdown
|
|
393
423
|
# Handoff — workstream registry
|
|
394
424
|
Date: 2026-06-11 (UTC)
|
|
395
425
|
|
|
426
|
+
<!-- GENERATED by sdlc_check.py index - do not edit by hand. Source of truth: the HANDOFF_*.md files in ai_docs/audit/. -->
|
|
427
|
+
|
|
396
428
|
| Workstream | Level | Branch | Status | Since | Next step | Details |
|
|
397
429
|
|---|---|---|---|---|---|---|
|
|
398
430
|
| F-001 SSO login | L3 | feature/sso-login | PROGRESS | 2026-06-10 | wire callback tests | HANDOFF_login_sso.md · ANALYSIS_login_sso.md |
|
|
399
|
-
| F-002 Audit refresh | L3 | feature/audit | PAUSED | 2026-06-02 | resume at Phase 4 |
|
|
431
|
+
| F-002 Audit refresh | L3 | feature/audit | PAUSED | 2026-06-02 | resume at Phase 4 | HANDOFF_audit_refresh.md · ANALYSIS_audit_refresh.md |
|
|
400
432
|
|
|
401
433
|
## Project-wide notes
|
|
402
|
-
|
|
434
|
+
|
|
435
|
+
<!-- from audit/project_notes.md: release pending, environment quirks that affect everyone -->
|
|
403
436
|
```
|
|
404
437
|
|
|
405
438
|
## ai_docs/audit/reviews/REVIEW_LOG.md
|
|
@@ -434,21 +467,47 @@ records the realization actually used — fresh subagent, one-shot client run, o
|
|
|
434
467
|
honest; writing nothing, or implying independence you did not have, is the failure
|
|
435
468
|
this column exists to prevent. `findings_real` is how many raised findings survived
|
|
436
469
|
triage: over time it is the only evidence of whether the gate earns its cost.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
470
|
+
Concurrent reviews: `init` writes a `.gitattributes` stanza giving this file
|
|
471
|
+
`merge=union` — a **built-in** driver (no per-clone `git config`, unlike
|
|
472
|
+
`merge=ours`, which silently does nothing until every clone configures it).
|
|
473
|
+
Rows are date-stamped and their order carries no meaning, so a union merge keeps
|
|
474
|
+
both sides instead of asking a human to choose. It is defence in depth: without
|
|
475
|
+
git, or without the stanza, the outcome is today's — one conflict you resolve by
|
|
476
|
+
hand, never a lost row.
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
## ai_docs/audit/HANDOFF_[topic].md — one open workstream, its own file
|
|
480
|
+
|
|
481
|
+
**The authored home of that workstream's registry row**, and the only one: the
|
|
482
|
+
registry is generated from these files. **One exists for every OPEN workstream**, with
|
|
483
|
+
or without volatile state — a workstream whose file is missing has no row, and a
|
|
484
|
+
workstream with no row is invisible to the next cold agent. **DELETED at the feature's
|
|
485
|
+
closure**, in the same step that flips the ANALYSIS to COMPLETED: deleting it *is*
|
|
486
|
+
removing the row.
|
|
487
|
+
|
|
488
|
+
**The DRY boundary, restated because the file is no longer rare.** What used to keep
|
|
489
|
+
narrative out of it was that it barely existed; now it always does. So: the ANALYSIS
|
|
490
|
+
Diary keeps **what happened and why** (decisions, state of the work — survives
|
|
491
|
+
forever), and this file keeps **the row plus the resume logistics** — how to pick the work back up (branch,
|
|
492
|
+
worktree, uncommitted state, the next concrete command — worthless once resumed).
|
|
493
|
+
Prose that would still be worth reading after closure is in the wrong file, because
|
|
494
|
+
this one is deleted.
|
|
495
|
+
|
|
496
|
+
The frontmatter IS the row. `workstream:` is what marks the file as a source: without
|
|
497
|
+
it the file is still a perfectly good volatile note, and nothing generates.
|
|
447
498
|
|
|
448
499
|
```markdown
|
|
500
|
+
---
|
|
501
|
+
workstream: F-001 SSO login
|
|
502
|
+
level: L3
|
|
503
|
+
branch: feature/sso-login (worktree ../wt-sso)
|
|
504
|
+
status: PROGRESS
|
|
505
|
+
since: 2026-06-10
|
|
506
|
+
next: wire the callback tests
|
|
507
|
+
details: ANALYSIS_login_sso.md
|
|
508
|
+
updated: 2026-06-11
|
|
509
|
+
---
|
|
449
510
|
# HANDOFF: [topic] (ephemeral — deleted at closure)
|
|
450
|
-
Updated: 2026-06-11 (UTC)
|
|
451
|
-
Branch: feature/sso-login (worktree ../wt-sso)
|
|
452
511
|
|
|
453
512
|
## Resume state
|
|
454
513
|
<!-- uncommitted files, half-run migrations, env vars, running services -->
|
|
@@ -460,6 +519,19 @@ Branch: feature/sso-login (worktree ../wt-sso)
|
|
|
460
519
|
<!-- traps discovered this session that bite on resume (locks, CRLF, flaky test) -->
|
|
461
520
|
```
|
|
462
521
|
|
|
522
|
+
`updated:` is the date this file last changed, and the newest one across all sources
|
|
523
|
+
becomes the registry's `Date:` header — which is why no writer ever edits that header
|
|
524
|
+
and why two concurrent writers no longer collide on it. `details:` holds the *other*
|
|
525
|
+
pointers (the ANALYSIS, a review log entry); the generator prepends this file's own
|
|
526
|
+
name, so nothing points at itself by hand.
|
|
527
|
+
|
|
528
|
+
## ai_docs/audit/project_notes.md — the registry's project-wide notes (source)
|
|
529
|
+
|
|
530
|
+
Plain lines, no frontmatter, appended verbatim to the generated registry under
|
|
531
|
+
`## Project-wide notes`. Release pending, environment quirks, anything true for
|
|
532
|
+
everyone rather than for one workstream. It exists so that generating the registry
|
|
533
|
+
cannot destroy notes that belong to no workstream.
|
|
534
|
+
|
|
463
535
|
## ai_docs/strategic/architecture.md and existing_features.md
|
|
464
536
|
|
|
465
537
|
Canonical docs: they open with the header (`description:`/`status:`) so they enter the `INDEX.md` manifest cleanly.
|
|
@@ -553,6 +625,7 @@ date: 2026-08-01
|
|
|
553
625
|
provenance: GIVEN
|
|
554
626
|
supersedes: contract-1a2b3c4d.pdf
|
|
555
627
|
extractor: pdftotext 24.02, form-feed page breaks, whitespace collapsed
|
|
628
|
+
extracted_through: p=212
|
|
556
629
|
---
|
|
557
630
|
Handed over by <who>, <context in one line>.
|
|
558
631
|
```
|
|
@@ -561,6 +634,18 @@ Handed over by <who>, <context in one line>.
|
|
|
561
634
|
without it the two content-addressed files are unrelated. `extractor:` pins the stored
|
|
562
635
|
canonical extraction (`<name>-<hash8>.txt`) that offset locators address.
|
|
563
636
|
|
|
637
|
+
`extracted_through:` is how far the source has been read — `p=<n>` (paged extraction),
|
|
638
|
+
`L<n>` (line file), or `complete`. It is **required once any claim cites this artifact**
|
|
639
|
+
and it is advanced at the end of every reading window (`distillation.md` §3): unstated,
|
|
640
|
+
"I am finished" is not falsifiable, which is exactly how a sampled 200-page manual passes
|
|
641
|
+
for an ingested one. Three things are checked: claims with no field (error); a claim
|
|
642
|
+
whose locator addresses **past** the declared coverage, or coverage past the end of the
|
|
643
|
+
stored bytes (error — the sidecar and the rows contradict each other); coverage short of
|
|
644
|
+
the end (warning, because partial work is legal mid-ingestion). Its limit is the mirror
|
|
645
|
+
image of `original_sha256`'s: **nothing proves a page was read**, so a field advanced
|
|
646
|
+
without extracting is caught at the ingestion review, never by the validator. An artifact
|
|
647
|
+
nobody has extracted from yet owes nothing and stays silent.
|
|
648
|
+
|
|
564
649
|
**Extraction-as-artifact** (`distillation.md` §1 — the variant for a large binary
|
|
565
650
|
corpus): when the extraction IS the artifact and the original was never copied in, two
|
|
566
651
|
more fields record where it came from.
|