Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ km gen-index [--check] regenerate every '## Documents' list in _index.md
km demote [--apply] demote served docs past their review date
km serve build the per-audience bundle under dist/served/
km promote ... move a note into the brain as a review doc
km approve DOC... --by WHO sign review docs off; ai:<model> only where the doc says approval: ai
km init DIR [--team ...] create a brain from the templates
km upgrade move a brain's km pins to this version
```
Expand Down
18 changes: 17 additions & 1 deletion skills/km/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ Do not offer for simple lookups or single-source answers.

All writes follow CONVENTIONS.md for frontmatter, folder placement, and naming. Always confirm before writing. Commit with `docs(<scope>): <description>`.

- **Status:** New documents always start as `status: draft`. Never set `status: accepted` without explicit user confirmation — always ask first.
- **Status:** New documents always start as `status: draft`. Never set `status: accepted` without explicit user confirmation — always ask first. Sign a doc off with `km approve <doc> --by <initials>` (see "Approval" below), not by editing `status` by hand.
- **Save:** Auto-detect type (note/concept/decision/transcript). Unclear folder → `inbox/`
- **Decision:** Extract title, context, alternatives, consequences. Use `type: decision`
- **Transcript:** Extract decisions + action items. Sections: Attendees, Summary, Decisions, Actions, Transcript
Expand Down Expand Up @@ -413,6 +413,22 @@ Known limitations (advisory scope, follow-ups): whole-repo mode sees TRACKED fil
per-file mode does not check reverse edges (a target flipping to `draft` is caught only in a
full run); link-form coverage is inline/reference/wiki/HTML/angle-bracket (not exhaustive).

## Approval (`km approve`)

`km approve <doc>... --by WHO [--at YYYY-MM-DD] [--review-by YYYY-MM-DD]` moves a `status: review`
doc to `accepted` and records `approved_by` / `approved_at`, then gates it with `km validate` and
restores it byte for byte if that fails. A doc whose `review_by` has passed (typically one CI
demoted) is refused until `--review-by` sets a new date.

- **Levels:** `approval: human` (the default when unset) needs a person; `approval: ai` lets an AI
review alone sign off, written `--by ai:<model>`. `audience: customer` docs and `verbatim-block`s
always need a person. `km validate` enforces it on signed-off docs (metadata, not authentication).
- **Pre-reviews:** `reviewed_by` (a list) records them, e.g. a PR bot's model, date and PR; it is not
an approval. `promote` and `--replace` drop it with the approval; a cross-repo promote also drops
`approval`, so the target brain decides its own level.
- **Required fields:** `status_rules` keys are `<status>_<requires|recommends|forbids>`, e.g.
`accepted_requires: [owner, review_by]` in `schema.local.yaml`.

## Index completeness (opt-in)

Set `check_index: true` at the top of `schema.local.yaml` to have `km validate` lint each
Expand Down
83 changes: 83 additions & 0 deletions src/km/approve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Sign review docs off: `status: accepted` plus `approved_by` and `approved_at`.

Who may sign off is the doc's `approval` level (see km.common.approval_problem); only a doc in
`status: review` can be approved. Each doc is edited in place (those three lines, no YAML round-trip,
like `km demote`) and then gated by `km validate`; a doc that fails is restored unchanged.

A doc whose `review_by` has already passed is refused unless `--review-by` sets a new date, or
the next `km demote` would take the approval straight back.

Run: km approve [--root DIR] <doc.md>... --by WHO [--at YYYY-MM-DD] [--review-by YYYY-MM-DD]
"""
from __future__ import annotations

import argparse
import datetime
import sys
from pathlib import Path

from km.common import ROOT, SERVED_STATUS, approval_problem, set_fm_line, split_frontmatter
from km.paths import write_text
from km.promote import run_gate


def approve_one(doc: str, by: str, at: datetime.date, review_by: datetime.date | None) -> str | None:
"""Sign one doc off. Returns why it was refused, or None."""
path = Path(doc).resolve()
try:
rel = path.relative_to(ROOT).as_posix()
except ValueError:
return f"{doc}: outside the brain"
if not path.is_file():
return f"{rel}: no such file"
old = path.read_bytes() # restored byte for byte if the gate fails (a BOM included)
text = old.decode("utf-8-sig").replace("\r\n", "\n") # strict decode (written back); CRLF like read()
fm, raw, body = split_frontmatter(text)
if fm is None:
return f"{rel}: no frontmatter"
if fm.get("status") != "review":
return f"{rel}: only a review doc can be approved (status: {fm.get('status')!r})"
why = approval_problem(fm, by)
if why:
return f"{rel}: {why}"
if review_by is None:
try:
due = datetime.date.fromisoformat(str(fm.get("review_by"))[:10]) if fm.get("review_by") else None
except ValueError:
return f"{rel}: review_by {fm.get('review_by')!r} is not a YYYY-MM-DD date; pass --review-by"
if due and due < max(at, datetime.date.today()): # km demote compares with today
return f"{rel}: review_by {due} has passed; pass --review-by with a new date"
lines = [("status", SERVED_STATUS), ("approved_by", f'"{by}"'), ("approved_at", at.isoformat())]
if review_by:
lines.append(("review_by", review_by.isoformat()))
for key, value in lines:
raw = set_fm_line(raw, key, value)
write_text(path, raw + body)
gate = run_gate(path)
if gate.returncode != 0:
path.write_bytes(old)
return f"{rel}: km validate fails after the change, left unchanged:\n{gate.stdout.rstrip()}"
print(f"approve: {rel} accepted, approved_by {by} on {at.isoformat()}")
return None


def main() -> int:
ap = argparse.ArgumentParser(prog="km approve")
ap.add_argument("docs", nargs="+")
ap.add_argument("--by", required=True, help="approver: initials, or ai:<model> where the doc says approval: ai")
ap.add_argument("--at", type=datetime.date.fromisoformat, default=datetime.date.today(),
help="approval date, YYYY-MM-DD")
ap.add_argument("--review-by", type=datetime.date.fromisoformat, help="set a new review_by date")
a = ap.parse_args()
if not a.by.strip():
ap.error("--by must name the approver")
if a.review_by and a.review_by < max(a.at, datetime.date.today()):
ap.error(f"--review-by {a.review_by} is in the past; the next km demote would undo the approval")
refused = [why for doc in a.docs if (why := approve_one(doc, a.by, a.at, a.review_by))]
for why in refused:
print(f"approve: REFUSED {why}")
return 1 if refused else 0


if __name__ == "__main__":
sys.exit(main())
1 change: 1 addition & 0 deletions src/km/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"demote": ("km.demote", "demote served docs past review_by (--apply to write)"),
"serve": ("km.serve", "build the served bundle under dist/served/"),
"promote": ("km.promote", "move a note into the brain as a review doc, or `promote stub`"),
"approve": ("km.approve", "sign review docs off as accepted (--by WHO)"),
"init": ("km.init", "create a brain: `init DIR --initials XX`, `--team ...` for a team brain"),
"upgrade": ("km.upgrade", "move a brain's km pins to this version; drop km files copied in earlier"),
}
Expand Down
34 changes: 34 additions & 0 deletions src/km/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,40 @@ def _merge(base: dict, local: dict | None) -> dict:
SERVED_STATUS = _gate.get("served_status", "accepted")
CUSTOMER_AUDIENCE = _gate.get("customer_audience", "customer")

# Approval: a doc may be signed off by an AI review alone only when it says `approval: ai`; a
# customer-facing doc or verbatim-block always needs a person. An AI approver is written
# `approved_by: "ai:<model>"`. Metadata, not authentication: it catches slips, not a determined author.
APPROVAL_FIELDS = ("approved_by", "approved_at")


def is_ai_approver(who) -> bool:
return str(who or "").strip().lower().startswith("ai:")


def human_only(fm: dict) -> bool:
return fm.get("audience") == CUSTOMER_AUDIENCE or fm.get("type") == "verbatim-block"


def approval_problem(fm: dict, approver=None) -> str | None:
"""Why `approver` (default: the doc's approved_by) may not sign this doc off, or None."""
if fm.get("approval") == "ai" and human_only(fm):
return "'approval: ai' is not allowed on a customer-facing doc or verbatim-block"
if is_ai_approver(fm.get("approved_by") if approver is None else approver) and fm.get("approval") != "ai":
return ("a customer-facing doc or verbatim-block needs a human approver" if human_only(fm)
else "an AI approver needs 'approval: ai'")
return None


def set_fm_line(raw: str, key: str, value: str) -> str:
"""Set `key: value` in a raw '---' frontmatter block in place: replace the line, or add it last."""
line = f"{key}: {value}"
new, n = re.subn(rf"(?m)^{re.escape(key)}:.*$", lambda _m: line, raw, count=1)
if n:
return new
end = raw.rstrip("\n").rfind("\n---")
return raw[:end] + "\n" + line + raw[end:]


_FM = re.compile(r"^(---\n(.*?)\n---\n?)(.*)$", re.S)


Expand Down
16 changes: 9 additions & 7 deletions src/km/promote.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* The candidate is gated in a temp file inside the repo, then atomically renamed onto the
target on PASS; a failing promote writes nothing and never clobbers.
* An existing served doc is NOT overwritten without --replace, and --replace INVALIDATES the
approval (status -> review, approved_* dropped, timestamp bumped).
approval (status -> review, approved_* and reviewed_by dropped, timestamp bumped).
* Slug collisions are a HARD STOP; a non-served target is refused; a peer-brain target is
refused (correct a foreign brain via a PR against its own repo, never in the parent).
* A verbatim-block is never --replace'd (change it only via supersede).
Expand Down Expand Up @@ -43,7 +43,7 @@

import yaml

from km.common import ROOT, SCHEMA
from km.common import APPROVAL_FIELDS, ROOT, SCHEMA
from km.paths import write_text


Expand Down Expand Up @@ -102,7 +102,8 @@ def run_gate(target: Path):
env={**os.environ, "PYTHONIOENCODING": "utf-8"}) # the child writes UTF-8 too


_PROVENANCE = ("status", "approved_by", "approved_at", "supersedes", "superseded_by", "contribution")
_REVIEW = (*APPROVAL_FIELDS, "reviewed_by") # sign-off and pre-reviews: they hold for the text they saw
_PROVENANCE = ("status", *_REVIEW, "supersedes", "superseded_by", "contribution")
_STUB_DROPS = _PROVENANCE + ("audience", "review_by") # a stub is never served
_NOT_STUBBABLE = {"README.md", "CLAUDE.md", "CONVENTIONS.md", "SKILL.md", "index.md", "log.md", "_index.md"}

Expand Down Expand Up @@ -289,8 +290,8 @@ def main() -> int:
return 2
fm = ex_fm
fm["status"] = "review"
fm.pop("approved_by", None)
fm.pop("approved_at", None)
for _k in _REVIEW: # a review of the old text no longer holds
fm.pop(_k, None)
fm["timestamp"] = today
target = existing
ident = {"type": src_fm.get("type"), "title": src_fm.get("title"),
Expand Down Expand Up @@ -331,8 +332,9 @@ def main() -> int:
fm["owner"] = a.owner
fm.setdefault("audience", "internal")
fm.setdefault("review_by", review_by)
if cross_repo: # these reference the SOURCE repo and do not resolve in the target
for _k in ("related", "sources", "translates", "project", "evidence"):
if cross_repo: # these reference the SOURCE repo and do not resolve in the target; and the
# target decides for itself whether an AI review may approve the doc (unset = human)
for _k in ("related", "sources", "translates", "project", "evidence", "approval"):
fm.pop(_k, None)
if isinstance(fm.get("resource"), str) and not fm["resource"].startswith(("http://", "https://")):
fm.pop("resource", None) # a non-web resource: URI points into the source machine/repo
Expand Down
7 changes: 7 additions & 0 deletions src/km/schema.base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ fields:
approved_at: # when sign-off happened (audit-grade record inside the repo)
required: false
format: iso8601
approval: # who may sign off: ai or human (default); rules in km.common.approval_problem
required: false
enum: [ai, human]
reviewed_by: # pre-reviews the doc went through (reviewer or model, date, PR); not an approval
required: false
type: list
audience: # replaces the fragile free-form `customer-facing` tag
required: false
enum: [internal, customer]
Expand Down Expand Up @@ -109,6 +115,7 @@ status_rules:
superseded_forbids: [contribution] # a stubbed doc no longer has a pending contribution
obsolete_forbids: [superseded_by]
accepted_recommends: [approved_by, approved_at] # soft rollout; flip to *_requires once backfilled
# accepted_requires: [owner, review_by] # a brain may make fields mandatory for accepted

# Universal defaults — brains EXTEND these in schema.local.yaml (lists are concatenated).
exempt_files: [CLAUDE.md, README.md, CONVENTIONS.md, SKILL.md]
Expand Down
6 changes: 6 additions & 0 deletions src/km/templates/team/CONVENTIONS.team.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ language: <en|de> # required on served docs
description: "One-sentence summary."
owner: <initials> # responsible for keeping it true (staleness)
review_by: 2027-01-15 # required once accepted
approval: <human|ai> # who may sign off; unset = human (see Status lifecycle)
approved_by: <initials | "ai:<model>"> # set by `km approve`, with approved_at
reviewed_by: ["<reviewer or model> <date> (PR #N)"] # pre-reviews, not an approval
ticket: ABC-123 # provenance to the work item this doc came from
resource: "https://…" # URI of an external asset (datasheet/repo/tracker)
project: <name>
Expand Down Expand Up @@ -90,6 +93,9 @@ draft → review → accepted → superseded | obsolete
```

- `accepted` = reviewed, current, in the served set. Requires `owner` + `review_by`.
- Sign-off is `km approve <doc> --by <initials>`. With `approval: ai` an AI review alone may sign
off (`--by ai:<model>`); without it a person must. Customer-facing docs and verbatim-blocks always
need a person.
- CI **demotes** an `accepted` doc back to `review` when its `review_by` lapses, so it drops out of
the served set automatically. Staleness is prevented, not just warned. An accepted doc that links
to a demoted one then fails the gate; the weekly run turns red so the maintainer re-reviews either.
Expand Down
4 changes: 4 additions & 0 deletions src/km/templates/team/schema.local.team.template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ gate:
# project_prefix: projects/ # subfolder names are project codes, flagged outside their folder
# project_code_pattern: 'p[0-9]+' # only matching subfolder names count as codes

# accepted means reviewed and current: it must name who keeps it true and when to re-check it.
status_rules:
accepted_requires: [owner, review_by]

# Repo-only fields (shallow-merged over the base schema).
fields:
ticket: # provenance to the work item this doc came from
Expand Down
55 changes: 37 additions & 18 deletions src/km/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@

import yaml

from km.common import EXEMPT, OVERLAY, ROOT, SCHEMA, tracked_md
from km.paths import git
from km.common import EXEMPT, OVERLAY, ROOT, SCHEMA, approval_problem, tracked_md
from km.paths import BASE_SCHEMA, git
from km.pins import movable_refs
from km.common import RESERVED as RESERVED_NF
from km.common import SKIP as SKIP_PREFIXES
Expand All @@ -57,7 +57,27 @@
FIELDS = SCHEMA["fields"]
TYPE_RULES = SCHEMA.get("type_rules", {})
SOFT_RULES = SCHEMA.get("type_rules_soft", {})
STATUS_RULES = SCHEMA.get("status_rules", {})
def _status_rules(raw) -> dict[tuple[str, str], list]:
"""status_rules keys are `<status>_<requires|recommends|forbids>`; a key the brain adds must also
name a status the schema knows. Anything else fails fast, so a typo never switches a rule off.
km's own keys are exempt from the status check: a brain may rename its statuses."""
known = set((FIELDS.get("status") or {}).get("enum") or [])
own = set((yaml.safe_load(BASE_SCHEMA.read_text(encoding="utf-8")) or {}).get("status_rules") or {})
out, bad = {}, []
for key, fields in (raw or {}).items():
st, _, kind = str(key).rpartition("_")
if (not st or (known and key not in own and st not in known)
or kind not in ("requires", "recommends", "forbids") or not isinstance(fields, list)):
bad.append(str(key))
else:
out[(st, kind)] = fields
if bad:
sys.exit(f"schema: status_rules key(s) {', '.join(bad)} must be <status>_<requires|recommends|forbids>: "
f"[fields], with a status from the schema")
return out


STATUS_RULES = _status_rules(SCHEMA.get("status_rules"))

# --- km gate config (opt-in) with fail-fast validation ---
_GATE_KEYS = {
Expand Down Expand Up @@ -536,21 +556,20 @@ def _term_errors(rel: str, scan: str) -> list[tuple[str, str, str]]:
warnings.append((f"soft:{t}", rel, f"type '{t}' should have '{rec}'"))

st = fm.get("status")
if st == "superseded":
for r in STATUS_RULES.get("superseded_requires", []):
if r not in fm:
errors.append(("status-rule", rel, f"superseded requires '{r}'"))
for r in STATUS_RULES.get("superseded_forbids", []):
if r in fm:
warnings.append(("status-rule", rel, f"superseded should not set '{r}'"))
if st == "obsolete":
for r in STATUS_RULES.get("obsolete_forbids", []):
if r in fm:
warnings.append(("status-rule", rel, f"obsolete should not set '{r}'"))
if st == "accepted":
for r in STATUS_RULES.get("accepted_recommends", []):
if r not in fm:
warnings.append(("status-soft", rel, f"accepted should record '{r}'"))
for (_st, _kind), _fields in STATUS_RULES.items():
if _st != st:
continue
for r in _fields:
if _kind == "requires" and r not in fm:
errors.append(("status-rule", rel, f"{st} requires '{r}'"))
elif _kind == "forbids" and r in fm:
warnings.append(("status-rule", rel, f"{st} should not set '{r}'"))
elif _kind == "recommends" and r not in fm:
warnings.append(("status-soft", rel, f"{st} should record '{r}'"))
# an ai: approver counts only on a signed-off doc; a contradicting `approval` always does
_why = approval_problem(fm, None if st == SERVED_STATUS else "")
if _why:
errors.append(("approval", rel, _why))

# --- km GATE (opt-in, advisory) — leak / bergab / freshness (secrets are a separate pass) ---
if GATE_ON:
Expand Down
Loading
Loading