@antoneeo/kb-agentic-skill 1.23.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/skills/kb-agentic-skill/SKILL.md +3 -3
- package/skills/kb-agentic-skill/scripts/knowledge.py +20 -6
- package/skills/kb-agentic-skill/scripts/sdlc_core.py +305 -0
- package/skills/kb-agentic-skill/templates.md +21 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Every significant change to this skill is recorded here.
|
|
4
4
|
|
|
5
|
+
## [1.24.0] - 2026-10-17
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- F-073: one written form for a dated entry, `**YYYY-MM-DD — label.**`, is taught for Diary entries, spike outcomes and project notes that record an event, and shared readers (`entry_openers`, `request_entries`, `adr_date`, `frontmatter_date`) let a chronology list those events the same way the validator reads them. Three new advisories, which never fail a build, not even `--strict`: undated text in the Diary of an L3 analysis started on or after 2026-10-18; a HANDOFF `next:` longer than 240 characters on an L3 file updated on or after that date (`next:` now holds only the next step, and the state of the work goes to the Diary); and, in `check` only, an open L3 unit whose id another branch or tag uses for a different analysis (one `git grep` over local refs, silent without git). The doctrine now says to choose a new unit's id after fetching, and that a session sharing a checkout with another session works in its own git worktree. A source sidecar's `date:` is now stated to be the day the source was received.
|
|
9
|
+
|
|
5
10
|
## [1.23.0] - 2026-10-17
|
|
6
11
|
|
|
7
12
|
### Changed
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kb-agentic-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"description": "Knowledge-Base & Document-First protocol with risk triage, Vision governance, signal distillation and optional devPNT integration.",
|
|
5
5
|
"author": "Antonio Pinto (https://github.com/Antoneeo)"
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antoneeo/kb-agentic-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"description": "Knowledge-Base & Document-First protocol for Claude Code, Gemini CLI, Google Antigravity and Codex with risk triage, Vision governance, signal distillation and optional devPNT integration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kb-agentic
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.24.0
|
|
4
4
|
description: Knowledge-Base & Document-First protocol with risk-proportional triage, Vision as a guide, Signal Distillation, a complete Standalone mode and optional symbiosis with devPNT. Use for user documentation, knowledge extraction, SOPs, research notes, decision logs, knowledge management — and for answering project questions from the claim ledger and topic graph.
|
|
5
5
|
author: Antonio Pinto (https://github.com/Antoneeo)
|
|
6
6
|
copyright: (c) 2026 Antonio Pinto
|
|
@@ -87,7 +87,7 @@ Triage decides IF documentation is due; this table decides WHICH document each e
|
|
|
87
87
|
| `solutions/SPIKE_[topic].md` | Closing any Spike — including a negative outcome. | — |
|
|
88
88
|
| `vision/features/VISION_[feature].md` | Multi-milestone topic at analysis time, or retroactive trigger (second ANALYSIS on same theme). | 3 |
|
|
89
89
|
| `audit/handoff.md` (workstream registry) | **Never by hand — generated by `sdlc_check.py index` from the `HANDOFF_*.md` sources**, and `validate` errors when the two disagree. Regenerate at every L3 closure (alignment or unit, Phase 5) and at session end — never at an ingestion step; the `Date:` header is derived, so no writer touches it. Inventory for lookup, never a work board. | 5 / session end |
|
|
90
|
-
| `audit/HANDOFF_[topic].md` | **One per OPEN workstream, with or without volatile state** — it is the authored home of that workstream's registry row (frontmatter `workstream`/`level`/`branch`/`status`/`since`/`next`/`details`/`updated`, plus `scope` for a unit that absorbs ingestion steps), so no file means no row. Carries the resume logistics too; the ANALYSIS Diary keeps the durable narrative (DRY). Frontmatter updated at a closure or session end, never at an ingestion step (a step adds only its body resume line). DELETED at unit closure — deleting it *is* removing the row. | 4 / 5 / session end |
|
|
90
|
+
| `audit/HANDOFF_[topic].md` | **One per OPEN workstream, with or without volatile state** — it is the authored home of that workstream's registry row (frontmatter `workstream`/`level`/`branch`/`status`/`since`/`next`/`details`/`updated`, plus `scope` for a unit that absorbs ingestion steps), so no file means no row. Its `next:` is the next concrete step in one sentence; what happened, and the state of the work, go to the Diary as a dated entry. Carries the resume logistics too; the ANALYSIS Diary keeps the durable narrative (DRY). Frontmatter updated at a closure or session end, never at an ingestion step (a step adds only its body resume line). DELETED at unit closure — deleting it *is* removing the row. | 4 / 5 / session end |
|
|
91
91
|
| `audit/project_notes.md` | A note true for the whole project rather than one workstream. Appended verbatim to the generated registry, so regenerating cannot destroy notes that belong to no workstream. | 5 / session end |
|
|
92
92
|
| `audit/handoff.md` — converting an existing project | **Lazily, at the first write — and then ALL AT ONCE.** Converting one row at a time is the state that loses the others, so `index` refuses to write while anything in the file is unaccounted for and names it. Until the first source exists nothing generates and nothing errors. | 5 / session end |
|
|
93
93
|
| `audit/audit_plan.md` (Standalone) | Bootstrap, and whenever a mapped area changes state (`sdlc_check.py mark`). | 1 |
|
|
@@ -141,7 +141,7 @@ Use this mode when `devpnt_*` tools are available and point at the current proje
|
|
|
141
141
|
- **Design review gate (end of Phase 3, before any drafting):** the analysis is reviewed by somebody other than its author — a subagent with fresh context, or a declared self-pass when no higher rung is usable — a permission-gated rung is asked about, never silently skipped (`review.md`). Follow `review.md`; log the outcome in `audit/reviews/REVIEW_LOG.md`. A knowledge structure reviewed only by the person who chose it is not reviewed.
|
|
142
142
|
|
|
143
143
|
### 4. Knowledge Processing & Distillation
|
|
144
|
-
- **Isolate the work (Branch/worktree hygiene).** Distillation rewrites existing notes — full re-read per `## Revision` — and does it on a branch or a worktree, never directly on the shared corpus, so a half-finished reconciliation is never what the next reader finds. Where a project does not isolate (one shared checkout), that is a departure from this rule, not a second mode: Rule Zero's step save commit and its declared residual apply.
|
|
144
|
+
- **Isolate the work (Branch/worktree hygiene).** Distillation rewrites existing notes — full re-read per `## Revision` — and does it on a branch or a worktree, never directly on the shared corpus, so a half-finished reconciliation is never what the next reader finds. Where a project does not isolate (one shared checkout), that is a departure from this rule, not a second mode: Rule Zero's step save commit and its declared residual apply. A session that may run while another session uses the same checkout works in its **own git worktree**, and never switches branch, stashes, resets or runs `index` in a checkout another session may be using. Parallel work also shares ids: choose a new unit's id **after fetching**, as the first id free on every branch and tag the checkout can see (`check` names a collision while the unit is open).
|
|
145
145
|
- Before drafting (L2/L3; L1 exempt), **consult the guide router** for a guide covering the task and read it first (the consult trigger, `guides.md` §0). A targeted description match, not a blanket read. Its result is the router verdict already declared with the triage level (Rule Zero).
|
|
146
146
|
- Execute knowledge extraction using **Signal Distillation** (`distillation.md`).
|
|
147
147
|
- **Opt-in subagent execution**: for an L3 with an approved analysis, the work MAY be executed via subagents per `dispatch.md`; default stays same-session. `dispatch.md` also carries the family-wide **delegation boundary** (what may be delegated at all, and what never may) — that section is not gated by the opt-in trigger and applies at any level, dispatch or no dispatch.
|
|
@@ -125,6 +125,21 @@ def kb_unquote(value):
|
|
|
125
125
|
return v
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def frontmatter_date(meta):
|
|
129
|
+
"""A note's or a sidecar's `date:` as `YYYY-MM-DD`, or None (F-073).
|
|
130
|
+
Quotes stripped, a datetime suffix tolerated, an impossible date refused --
|
|
131
|
+
the one parser behind orient's recency probe and any chronology reader."""
|
|
132
|
+
m = re.match(r"(\d{4}-\d{2}-\d{2})", kb_unquote((meta or {}).get("date")))
|
|
133
|
+
if not m:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
import datetime as _dt
|
|
137
|
+
_dt.date.fromisoformat(m.group(1))
|
|
138
|
+
except ValueError:
|
|
139
|
+
return None
|
|
140
|
+
return m.group(1)
|
|
141
|
+
|
|
142
|
+
|
|
128
143
|
def kb_parse_scope(text):
|
|
129
144
|
"""'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
|
|
130
145
|
('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
|
|
@@ -1945,14 +1960,13 @@ def kb_cmd_orient(argv):
|
|
|
1945
1960
|
# it -- the file was already fully read, so the cap bought
|
|
1946
1961
|
# only failure. load_frontmatter is the authoritative
|
|
1947
1962
|
# reader (fence-bounded, so a column-0 date: in the body
|
|
1948
|
-
# still cannot misdate the note);
|
|
1949
|
-
# dates
|
|
1963
|
+
# still cannot misdate the note); frontmatter_date covers
|
|
1964
|
+
# quoted dates and tolerates a datetime suffix.
|
|
1950
1965
|
meta = sdlc_core.load_frontmatter(
|
|
1951
1966
|
sdlc_core.read_text(p).splitlines()) or {}
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
stamp = _dt.date.fromisoformat(m.group(1))
|
|
1967
|
+
day = frontmatter_date(meta)
|
|
1968
|
+
if day:
|
|
1969
|
+
stamp = _dt.date.fromisoformat(day)
|
|
1956
1970
|
except Exception:
|
|
1957
1971
|
pass
|
|
1958
1972
|
if stamp is None:
|
|
@@ -89,6 +89,14 @@ ARCHITECT_PASS_EPOCH = "2026-07-28"
|
|
|
89
89
|
# row. Same grandfathering discipline as the pass above -- never nag work that
|
|
90
90
|
# predates the rule.
|
|
91
91
|
DESIGN_REVIEW_EPOCH = "2026-07-28"
|
|
92
|
+
# Chronology (F-073): an L3 ANALYSIS started, or an L3 HANDOFF updated, on/after
|
|
93
|
+
# this date is read for undated Diary text and a multi-step `next:`. Advisories,
|
|
94
|
+
# like the two gates above: a fixed epoch cannot protect a late upgrader, so a
|
|
95
|
+
# warning here would redden --strict CI on work written before the upgrade.
|
|
96
|
+
CHRONOLOGY_EPOCH = "2026-10-18"
|
|
97
|
+
NEXT_MAX_CHARS = 240 # this repository's longest `next:` was 214 at the epoch
|
|
98
|
+
ID_SCAN_MAX_REFS = 300 # A3 is skipped above it (one advisory says so, T9)
|
|
99
|
+
ID_SCAN_REFS_PER_CALL = 50 # keeps one git command line far below 32k chars
|
|
92
100
|
def review_log_rel():
|
|
93
101
|
return f"{docs_dir()}/audit/reviews/REVIEW_LOG.md"
|
|
94
102
|
# Component Map 'Where' refs: a dotted token counts as a path only with one of
|
|
@@ -1101,6 +1109,298 @@ def section_body(text, aliases):
|
|
|
1101
1109
|
return None
|
|
1102
1110
|
|
|
1103
1111
|
|
|
1112
|
+
def section_lines(text, aliases):
|
|
1113
|
+
"""(heading_line, body) for the first matching `## ` section, or None: the
|
|
1114
|
+
body NOT stripped, so body line n is file line heading_line + n, and an
|
|
1115
|
+
indented first line keeps its indentation. The readers below use it; a
|
|
1116
|
+
stripped body would shift every line number and move ` - DATE` to column 0."""
|
|
1117
|
+
lines = (text or "").splitlines()
|
|
1118
|
+
for i, line in enumerate(lines):
|
|
1119
|
+
if any(line.startswith(a) for a in aliases):
|
|
1120
|
+
body = []
|
|
1121
|
+
for nxt in lines[i + 1:]:
|
|
1122
|
+
if nxt.startswith("## "):
|
|
1123
|
+
break
|
|
1124
|
+
body.append(nxt)
|
|
1125
|
+
return i + 1, "\n".join(body)
|
|
1126
|
+
return None
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
# ---------------------------------------------------------------- chronology
|
|
1130
|
+
# One grammar for a dated entry (F-073), read by the validator and by any
|
|
1131
|
+
# consumer that lists events (F-072's chronology): both call these readers, so
|
|
1132
|
+
# they cannot disagree on what an event is. The readers return openers only --
|
|
1133
|
+
# date, label, line -- and format nothing for display.
|
|
1134
|
+
|
|
1135
|
+
DIARY_SECTION = next(s for s in ANALYSIS_SECTIONS if s[0] == "## Diary")
|
|
1136
|
+
_ISO = r"\d{4}-\d{2}-\d{2}"
|
|
1137
|
+
# At column 0: optional list marker, optional `###`/`####`, optional bold, then
|
|
1138
|
+
# the ISO date and a word boundary. The date FIRST is the rule; markup is
|
|
1139
|
+
# tolerated so the dated forms already written stay readable with no migration.
|
|
1140
|
+
ENTRY_OPENER = re.compile(r"^(?:([-*]) |(#{3,4}) )?(\*\*)?(" + _ISO + r")\b(.*)$")
|
|
1141
|
+
_LABEL_SEP = re.compile(r"^(?:\*\*)?\s*(?:[—–:-])?\s*")
|
|
1142
|
+
_REQUEST_HEADING = re.compile(r"^### (R\d+) [—–-] (" + _ISO + r") [—–-] (.+)$")
|
|
1143
|
+
_ADR_BODY_DATE = re.compile(r"^\**Date:\**\s*(" + _ISO + r")\b", re.M)
|
|
1144
|
+
_ADR_NAME_DATE = re.compile(r"^ADR_(" + _ISO + r")_", re.I)
|
|
1145
|
+
UNIT_ID_RE = re.compile(r"\b([A-Z]-\d{3,})\b")
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _entry_label(bold, rest):
|
|
1149
|
+
rest = _LABEL_SEP.sub("", rest, count=1)
|
|
1150
|
+
if bold and "**" in rest:
|
|
1151
|
+
rest = rest.split("**", 1)[0]
|
|
1152
|
+
else:
|
|
1153
|
+
rest = re.split(r"(?<=[.!?])\s", rest, maxsplit=1)[0]
|
|
1154
|
+
return rest.strip(" .:—–-*")[:120]
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _opener_at(lines, i):
|
|
1158
|
+
"""The match when lines[i] opens an entry. It must also START A BLOCK -- the
|
|
1159
|
+
line above is blank, absent or a heading, or the line is itself a list item
|
|
1160
|
+
or a heading -- so a hard-wrapped prose line that happens to begin with a
|
|
1161
|
+
date is never an event. (A separator after the date is NOT required: real
|
|
1162
|
+
openers read `DATE (qualifier)` and `DATE word`.)"""
|
|
1163
|
+
m = ENTRY_OPENER.match(lines[i])
|
|
1164
|
+
if not m:
|
|
1165
|
+
return None
|
|
1166
|
+
prev = lines[i - 1] if i else ""
|
|
1167
|
+
if not prev.strip() or prev.startswith("#") or m.group(1) or m.group(2):
|
|
1168
|
+
return m
|
|
1169
|
+
return None
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def entry_openers(body):
|
|
1173
|
+
"""[(date, label, line)] -- one per dated entry opener; line is 1-based
|
|
1174
|
+
within `body`. An undated paragraph belongs to the entry above it. Comments
|
|
1175
|
+
are blanked first, exactly as `undated_diary_lines` does, so an entry
|
|
1176
|
+
written under the template's comment is an event to both."""
|
|
1177
|
+
lines = _blank_comments(body).splitlines()
|
|
1178
|
+
out = []
|
|
1179
|
+
for i in range(len(lines)):
|
|
1180
|
+
m = _opener_at(lines, i)
|
|
1181
|
+
if m:
|
|
1182
|
+
out.append((m.group(4), _entry_label(m.group(3), m.group(5)), i + 1))
|
|
1183
|
+
return out
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _blank_comments(text):
|
|
1187
|
+
# comments become blank lines, so line numbers still point at the file's lines
|
|
1188
|
+
return re.sub(r"<!--.*?-->", lambda m: "\n" * m.group(0).count("\n"), text or "",
|
|
1189
|
+
flags=re.S)
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def undated_diary_lines(body):
|
|
1193
|
+
"""[(line, text)] a Diary body cannot date: text before the first opener,
|
|
1194
|
+
and any `### ` heading that is not an opener. `####` and deeper headings are
|
|
1195
|
+
sub-structure of an entry and stay legal. Comments are ignored."""
|
|
1196
|
+
lines = _blank_comments(body).splitlines()
|
|
1197
|
+
out, seen_opener = [], False
|
|
1198
|
+
for i, line in enumerate(lines):
|
|
1199
|
+
if _opener_at(lines, i):
|
|
1200
|
+
seen_opener = True
|
|
1201
|
+
elif line.startswith("### "):
|
|
1202
|
+
out.append((i + 1, line.strip()))
|
|
1203
|
+
elif not seen_opener and line.strip():
|
|
1204
|
+
out.append((i + 1, line.strip()))
|
|
1205
|
+
return out
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def request_entries(text):
|
|
1209
|
+
"""(entries, unparsed) for a request record: entries are
|
|
1210
|
+
[(rid, date, label, line)]; a `### ` heading that does not match
|
|
1211
|
+
`### R<n> — YYYY-MM-DD — <label>` is returned in unparsed as (line, text),
|
|
1212
|
+
never dropped."""
|
|
1213
|
+
entries, unparsed = [], []
|
|
1214
|
+
for i, line in enumerate((text or "").splitlines()):
|
|
1215
|
+
if not line.startswith("### "):
|
|
1216
|
+
continue
|
|
1217
|
+
m = _REQUEST_HEADING.match(line.rstrip())
|
|
1218
|
+
if m:
|
|
1219
|
+
entries.append((m.group(1), m.group(2), m.group(3).strip(), i + 1))
|
|
1220
|
+
else:
|
|
1221
|
+
unparsed.append((i + 1, line.rstrip()))
|
|
1222
|
+
return entries, unparsed
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def adr_date(name, text):
|
|
1226
|
+
"""An ADR's date: `**Date:** YYYY-MM-DD` in the body, else the
|
|
1227
|
+
`ADR_YYYY-MM-DD_*` file name, else None."""
|
|
1228
|
+
m = _ADR_BODY_DATE.search(text or "") or _ADR_NAME_DATE.match(name or "")
|
|
1229
|
+
return m.group(1) if m else None
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def _meta_date(meta, key):
|
|
1233
|
+
return parse_iso(str(meta.get(key) or "").strip().strip("'\"")[:10])
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def chronology_advisories(root):
|
|
1237
|
+
"""A1 (undated Diary text) and A2 (`next:` is not one step), as advisory
|
|
1238
|
+
strings. Gated on `level: L3` and on a date the file carries -- never an
|
|
1239
|
+
mtime -- so L1/L2 work and anything written before the epoch see nothing."""
|
|
1240
|
+
epoch = parse_iso(CHRONOLOGY_EPOCH)
|
|
1241
|
+
out = []
|
|
1242
|
+
for p, meta, text in list_analyses(root):
|
|
1243
|
+
started = _meta_date(meta, "start_date")
|
|
1244
|
+
if str(meta.get("level", "")).upper() != "L3" or not started or started < epoch:
|
|
1245
|
+
continue
|
|
1246
|
+
section = section_lines(text, DIARY_SECTION)
|
|
1247
|
+
if section is None:
|
|
1248
|
+
continue
|
|
1249
|
+
head, body = section
|
|
1250
|
+
rel = "solutions/" + p.name
|
|
1251
|
+
lines = undated_diary_lines(body)
|
|
1252
|
+
preface = [(n, t) for n, t in lines if not t.startswith("### ")]
|
|
1253
|
+
if preface:
|
|
1254
|
+
out.append(f"{rel}: line {head + preface[0][0]}: Diary text '{preface[0][1][:60]}' "
|
|
1255
|
+
"comes before the first dated entry -- open entries with "
|
|
1256
|
+
"**YYYY-MM-DD — label.**, and write the current state as the latest "
|
|
1257
|
+
"dated entry")
|
|
1258
|
+
for n, t in lines:
|
|
1259
|
+
if t.startswith("### "):
|
|
1260
|
+
out.append(f"{rel}: line {head + n}: Diary heading '{t[:60]}' is not a dated "
|
|
1261
|
+
"entry -- open entries with **YYYY-MM-DD — label.**")
|
|
1262
|
+
for p, meta in list_workstreams(root):
|
|
1263
|
+
updated = _meta_date(meta, "updated")
|
|
1264
|
+
if str(meta.get("level", "")).upper() != "L3" or not updated or updated < epoch:
|
|
1265
|
+
continue
|
|
1266
|
+
nxt = str(meta.get("next") or "").strip()
|
|
1267
|
+
if len(nxt) > NEXT_MAX_CHARS:
|
|
1268
|
+
out.append(f"audit/{p.name}: next: is {len(nxt)} chars (> {NEXT_MAX_CHARS}): one "
|
|
1269
|
+
"next step here, the state goes to the Diary as a dated entry")
|
|
1270
|
+
return out
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def _open_units(root):
|
|
1274
|
+
"""{id: {analysis name}} for the tree's open L3 units: an L3 ANALYSIS that is
|
|
1275
|
+
PLANNED or IN_PROGRESS, or a request record (L3 only, r26) whose `analysis:`
|
|
1276
|
+
is absent from the tree or names such an open ANALYSIS. A closed unit's
|
|
1277
|
+
record, kept forever, does not count."""
|
|
1278
|
+
units, present, open_names = {}, set(), set()
|
|
1279
|
+
for p, meta, _ in list_analyses(root):
|
|
1280
|
+
present.add(p.name)
|
|
1281
|
+
if (str(meta.get("level", "")).upper() == "L3"
|
|
1282
|
+
and meta.get("status") in ("PLANNED", "IN_PROGRESS")):
|
|
1283
|
+
m = UNIT_ID_RE.search(str(meta.get("id") or ""))
|
|
1284
|
+
if m:
|
|
1285
|
+
open_names.add(p.name)
|
|
1286
|
+
units.setdefault(m.group(1), set()).add(p.name)
|
|
1287
|
+
req = ai_path(root, "audit", "requests")
|
|
1288
|
+
if req.is_dir():
|
|
1289
|
+
for p in sorted(req.glob("REQUEST_*.md")):
|
|
1290
|
+
meta = load_frontmatter(read_text(p).splitlines())
|
|
1291
|
+
name = str(meta.get("analysis") or "").strip().strip("'\"`")
|
|
1292
|
+
m = UNIT_ID_RE.search(str(meta.get("unit") or meta.get("id") or ""))
|
|
1293
|
+
if m and name and (name not in present or name in open_names):
|
|
1294
|
+
units.setdefault(m.group(1), set()).add(name)
|
|
1295
|
+
return units
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _git_out(root, *args, timeout=30):
|
|
1299
|
+
r = subprocess.run(["git", *args], cwd=str(root), capture_output=True, text=True,
|
|
1300
|
+
encoding="utf-8", errors="replace", timeout=timeout)
|
|
1301
|
+
return r.stdout if r.returncode == 0 else None
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def ids_on_other_refs(root, max_refs=ID_SCAN_MAX_REFS):
|
|
1305
|
+
"""[(id, mine, theirs, [refs])] -- an open unit's id that another ref (heads,
|
|
1306
|
+
remotes, tags) uses for a DIFFERENT analysis. Identity is the ANALYSIS file
|
|
1307
|
+
name, declared by the ANALYSIS or announced by a request record's
|
|
1308
|
+
`analysis:`. One `git grep` over local refs, fixed arguments only (T8);
|
|
1309
|
+
as fresh as the last fetch. [] with no open L3 unit or no git; None when
|
|
1310
|
+
skipped -- above `max_refs` refs, or git failed or timed out -- so the
|
|
1311
|
+
caller can say the check did not run instead of reporting it clean."""
|
|
1312
|
+
mine = _open_units(root)
|
|
1313
|
+
if not mine or not git_available(root):
|
|
1314
|
+
return []
|
|
1315
|
+
try:
|
|
1316
|
+
head = (_git_out(root, "rev-parse", "HEAD") or "").strip()
|
|
1317
|
+
listing = _git_out(root, "for-each-ref",
|
|
1318
|
+
"--format=%(objectname) %(objecttype) %(*objecttype) %(refname)",
|
|
1319
|
+
"refs/heads", "refs/remotes", "refs/tags")
|
|
1320
|
+
if listing is None:
|
|
1321
|
+
return None
|
|
1322
|
+
refs = []
|
|
1323
|
+
for line in listing.split("\n"):
|
|
1324
|
+
parts = line.split(" ")
|
|
1325
|
+
if len(parts) == 3: # %(*objecttype) empty: not a tag
|
|
1326
|
+
parts.insert(2, "")
|
|
1327
|
+
if len(parts) != 4:
|
|
1328
|
+
continue
|
|
1329
|
+
sha, kind, peeled, ref = parts
|
|
1330
|
+
# only refs that resolve to a commit can be grepped (a tag on a blob
|
|
1331
|
+
# would fail the whole batch); the tree's own commit is not "another
|
|
1332
|
+
# ref"; symbolic HEADs repeat a branch
|
|
1333
|
+
if "commit" not in (kind, peeled) or ref.endswith("/HEAD") or sha == head:
|
|
1334
|
+
continue
|
|
1335
|
+
refs.append(ref)
|
|
1336
|
+
if not refs:
|
|
1337
|
+
return []
|
|
1338
|
+
if len(refs) > max_refs:
|
|
1339
|
+
return None
|
|
1340
|
+
# Pathspecs are relative to cwd=root, so a docs root inside a monorepo
|
|
1341
|
+
# subdirectory is found. -z keeps a path with ':' or non-ASCII intact
|
|
1342
|
+
# (quotePath off); -n plus the fence lines confine hits to frontmatter.
|
|
1343
|
+
docs = docs_dir()
|
|
1344
|
+
chunks = []
|
|
1345
|
+
for i in range(0, len(refs), ID_SCAN_REFS_PER_CALL): # command-line length
|
|
1346
|
+
r = subprocess.run(
|
|
1347
|
+
["git", "-c", "core.quotePath=false", "grep", "-z", "-n", "-I", "-E",
|
|
1348
|
+
r"^(id|unit|analysis):|^---\s*$", *refs[i:i + ID_SCAN_REFS_PER_CALL],
|
|
1349
|
+
"--", f"{docs}/solutions/ANALYSIS_*.md", f"{docs}/audit/requests/REQUEST_*.md"],
|
|
1350
|
+
cwd=str(root), capture_output=True, text=True, encoding="utf-8",
|
|
1351
|
+
errors="replace", timeout=60)
|
|
1352
|
+
if r.returncode not in (0, 1): # 1 = no match
|
|
1353
|
+
return None
|
|
1354
|
+
chunks.append(r.stdout)
|
|
1355
|
+
except Exception:
|
|
1356
|
+
return None
|
|
1357
|
+
per = {}
|
|
1358
|
+
for line in "".join(chunks).split("\n"): # not splitlines: \x1c.. are content
|
|
1359
|
+
parts = line.split("\0")
|
|
1360
|
+
if len(parts) != 3 or ":" not in parts[0] or not parts[1].isdigit():
|
|
1361
|
+
continue
|
|
1362
|
+
ref, path = parts[0].split(":", 1) # a ref name cannot hold ':'
|
|
1363
|
+
content = parts[2].rstrip("\r")
|
|
1364
|
+
st = per.setdefault((ref, path), {"fences": 0, "kv": {}})
|
|
1365
|
+
if int(parts[1]) == 1 and content.strip() == "---":
|
|
1366
|
+
st["fences"] = 1 # frontmatter opens on line 1
|
|
1367
|
+
elif st["fences"] == 1 and content.strip() == "---":
|
|
1368
|
+
st["fences"] = 2 # ...and closes here
|
|
1369
|
+
elif st["fences"] == 1:
|
|
1370
|
+
key, _, val = content.partition(":")
|
|
1371
|
+
st["kv"].setdefault(key, val.strip())
|
|
1372
|
+
found = {}
|
|
1373
|
+
for (ref, path), st in per.items():
|
|
1374
|
+
kv = st["kv"]
|
|
1375
|
+
fname = path.replace("\\", "/").rsplit("/", 1)[-1]
|
|
1376
|
+
name = fname if fname.startswith("ANALYSIS_") else kv.get("analysis", "").strip("'\"`")
|
|
1377
|
+
m = UNIT_ID_RE.search(kv.get("id") or kv.get("unit") or "")
|
|
1378
|
+
if not (m and name) or m.group(1) not in mine or name in mine[m.group(1)]:
|
|
1379
|
+
continue
|
|
1380
|
+
short = re.sub(r"^refs/(heads|remotes|tags)/", "", ref)
|
|
1381
|
+
found.setdefault((m.group(1), name), [])
|
|
1382
|
+
if short not in found[(m.group(1), name)]:
|
|
1383
|
+
found[(m.group(1), name)].append(short)
|
|
1384
|
+
return sorted((fid, ", ".join(sorted(mine[fid])), theirs, sorted(refs_))
|
|
1385
|
+
for (fid, theirs), refs_ in found.items())
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def id_advisories(root):
|
|
1389
|
+
"""A3 as advisory strings, for every entry point's `check`."""
|
|
1390
|
+
hits = ids_on_other_refs(root)
|
|
1391
|
+
if hits is None:
|
|
1392
|
+
return [f"id check skipped (more than {ID_SCAN_MAX_REFS} refs, or git grep failed "
|
|
1393
|
+
"or timed out) -- choose a new unit's id after fetching, as the first id "
|
|
1394
|
+
"free on every ref"]
|
|
1395
|
+
out = []
|
|
1396
|
+
for fid, mine, theirs, refs in hits:
|
|
1397
|
+
more = f" (+{len(refs) - 1} more)" if len(refs) > 1 else ""
|
|
1398
|
+
out.append(f"id {fid} ({mine}) is also used by {theirs} on {refs[0]}{more}: choose "
|
|
1399
|
+
"the first id free on every ref -- if this is the same unit under its "
|
|
1400
|
+
"old name, ignore it")
|
|
1401
|
+
return out
|
|
1402
|
+
|
|
1403
|
+
|
|
1104
1404
|
def design_review_due(meta):
|
|
1105
1405
|
"""True when an L3 ANALYSIS owes a design-review row (review.md moment 1):
|
|
1106
1406
|
implementation has started or finished, and it began on/after the gate
|
|
@@ -1556,6 +1856,7 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1556
1856
|
except ValueError:
|
|
1557
1857
|
warnings.append("audit/handoff.md: date not parseable")
|
|
1558
1858
|
|
|
1859
|
+
advisories.extend(chronology_advisories(root)) # A1, A2 (F-073)
|
|
1559
1860
|
for a in advisories:
|
|
1560
1861
|
print(f"[note] {a}")
|
|
1561
1862
|
for w in warnings:
|
|
@@ -1925,6 +2226,10 @@ def cmd_check(root, strict=False, hybrid=False, extra_validate=None, extra_secti
|
|
|
1925
2226
|
rc_v = cmd_validate(root, strict=strict, hybrid=hybrid)
|
|
1926
2227
|
if extra_validate is not None:
|
|
1927
2228
|
rc_v = max(rc_v, extra_validate())
|
|
2229
|
+
# A3 (F-073): check only -- it reads git refs, and validate stays file-local.
|
|
2230
|
+
# Advisory: printed, never part of any rc.
|
|
2231
|
+
for a in id_advisories(root):
|
|
2232
|
+
print(f"[note] {a}")
|
|
1928
2233
|
print("\n===== stale =====")
|
|
1929
2234
|
rc_s = cmd_stale(root, hybrid=hybrid)
|
|
1930
2235
|
rcs = [("validate", rc_v), ("stale", rc_s)]
|
|
@@ -255,7 +255,7 @@ Context (agent): "the question it answered, quoted"
|
|
|
255
255
|
|
|
256
256
|
## ai_docs/solutions/ANALYSIS_[feature_name].md
|
|
257
257
|
|
|
258
|
-
The frontmatter is the source of truth for the feature state (the `features_history.md` index is generated from it).
|
|
258
|
+
The frontmatter is the source of truth for the feature state (the `features_history.md` index is generated from it). Choose a new unit's `id:` **after fetching**, as the first id free on every branch and tag the checkout can see: `check` names an open L3 unit whose id another ref uses for a different analysis.
|
|
259
259
|
|
|
260
260
|
```markdown
|
|
261
261
|
---
|
|
@@ -334,8 +334,13 @@ CONTESTED: none.
|
|
|
334
334
|
explicit alternative verification and reason. -->
|
|
335
335
|
|
|
336
336
|
## Diary / Current State
|
|
337
|
-
<!--
|
|
338
|
-
|
|
337
|
+
<!-- One dated entry per event, newest last. Open every entry with its date, once:
|
|
338
|
+
**YYYY-MM-DD — label.** body
|
|
339
|
+
The latest entry IS the current state (where I am, last problem, next step),
|
|
340
|
+
dated the day it was written. An undated paragraph is read as part of the
|
|
341
|
+
dated entry above it, so a new event never starts undated; `####`
|
|
342
|
+
sub-headings inside an entry are fine. It is the handoff source for this
|
|
343
|
+
feature. -->
|
|
339
344
|
```
|
|
340
345
|
|
|
341
346
|
Allowed frontmatter states: `PLANNED` | `IN_PROGRESS` | `COMPLETED` | `CANCELLED`. `COMPLETED` requires `end_date`. (The validator also accepts the deprecated Italian keys `stato`/`livello`/`data_inizio`/`data_fine` in existing projects.)
|
|
@@ -349,6 +354,7 @@ Allowed frontmatter states: `PLANNED` | `IN_PROGRESS` | `COMPLETED` | `CANCELLED
|
|
|
349
354
|
## Time-box
|
|
350
355
|
## What was tried
|
|
351
356
|
## Answer / Outcome
|
|
357
|
+
<!-- open with **YYYY-MM-DD — label.**, dated the day the answer was reached -->
|
|
352
358
|
## Consequences
|
|
353
359
|
<!-- max 1 page. Spike code is NOT mergeable: for production reclassify L2/L3. -->
|
|
354
360
|
```
|
|
@@ -599,6 +605,12 @@ tested against (`SKILL.md` Rule Zero, *An open unit absorbs*): the topics, front
|
|
|
599
605
|
counterpart the unit covers — on a live front, front-wide ("K-02: every source and
|
|
600
606
|
statement on X"). The registry does not show it.
|
|
601
607
|
|
|
608
|
+
`next:` is **the next concrete step, in one sentence** (at most 240 characters;
|
|
609
|
+
`validate` notes a longer one on an L3 file updated under the rule). What happened,
|
|
610
|
+
and the state of the work, go to the Diary as a dated entry: the registry is what
|
|
611
|
+
`orient` shows at every session start, cut at a fixed size, so a paragraph here
|
|
612
|
+
costs the next agent the rows below it.
|
|
613
|
+
|
|
602
614
|
## ai_docs/audit/project_notes.md — the registry's project-wide notes (source)
|
|
603
615
|
|
|
604
616
|
Plain lines, no frontmatter, appended verbatim to the generated registry under
|
|
@@ -606,6 +618,10 @@ Plain lines, no frontmatter, appended verbatim to the generated registry under
|
|
|
606
618
|
everyone rather than for one workstream. It exists so that generating the registry
|
|
607
619
|
cannot destroy notes that belong to no workstream.
|
|
608
620
|
|
|
621
|
+
A note that records an **event** (a release, a migration, an incident) opens with its
|
|
622
|
+
date — `**YYYY-MM-DD — label.**` — so it is read as one; a **standing** note (an
|
|
623
|
+
environment quirk) stays undated, and is not an event.
|
|
624
|
+
|
|
609
625
|
## ai_docs/strategic/architecture.md and existing_features.md
|
|
610
626
|
|
|
611
627
|
Canonical docs: they open with the header (`description:`/`status:`) so they enter the `INDEX.md` manifest cleanly.
|
|
@@ -708,6 +724,8 @@ extracted_through: p=212
|
|
|
708
724
|
Handed over by <who>, <context in one line>.
|
|
709
725
|
```
|
|
710
726
|
|
|
727
|
+
`date:` is **the day the source was received** — the date a chronology reads for it.
|
|
728
|
+
|
|
711
729
|
`supersedes:` is what makes "which claims rest on a superseded version" answerable —
|
|
712
730
|
without it the two content-addressed files are unrelated. `extractor:` pins the stored
|
|
713
731
|
canonical extraction (`<name>-<hash8>.txt`) that offset locators address.
|