Files
chemenu/tools/chemenu/commands/log_append.py
T
torben df8ff2fa22
CI / verify (push) Successful in 1m17s
Release / release (push) Successful in 37s
tools: command records, Catalog and log group - bullets, examples, prohibitions (#142)
Files changed:
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/index_build.py
- tools/chemenu/commands/log_append.py
2026-09-26 08:28:38 +02:00

157 lines
6.3 KiB
Python

"""Append correctly-formatted entries to kb/log.md."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Optional
import typer
from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success, today_iso
app = typer.Typer(help="Manage kb/log.md.")
VALID_OPS = ["ingest", "query", "lint", "create", "update", "delete", "rename", "move"]
# Matches the "## [YYYY-MM-DD] op | title" heading `format_log_entry` writes,
# in file order (oldest first, since entries are appended).
LOG_ENTRY_RE = re.compile(r"^## \[(\d{4}-\d{2}-\d{2})\] (\S+) \| (.+)$", re.MULTILINE)
def format_log_entry(op: str, title: str, body: str = "", today: Optional[str] = None) -> str:
today = today or today_iso()
entry = f"## [{today}] {op} | {title}\n"
if body.strip():
entry += f"\n{body.strip()}\n"
entry += "\n---\n"
return entry
def parse_log_entries(text: str) -> list[tuple[str, str, str]]:
"""Return every logged (date, op, title) entry in file order."""
return [(m.group(1), m.group(2), m.group(3)) for m in LOG_ENTRY_RE.finditer(text)]
def ingests_since_last_lint(entries: list[tuple[str, str, str]]) -> int:
"""Count `ingest` entries logged after the most recent `lint` entry (or
since the start of the log, if it has never been linted).
This is the deterministic count behind the Maintenance Schedule's "every
10 sources" full-lint cadence: nothing else in the system tracks it, so
without this the claim was prose with no enforcement - an agent (or user)
had to remember to count."""
count = 0
for _date, op, _title in entries:
if op == "lint":
count = 0
elif op == "ingest":
count += 1
return count
@app.command("append")
@cli_contract.record(cli_contract.CommandRecord(
path="log append",
summary="Append a formatted entry to `kb/log.md`.",
synopsis=(cli_contract.Variant(
usage="log append --op ingest|query|lint|create|update|delete|rename|move "
'--title "..." [--body "..."|--body-file path]',
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="Yes - single append",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Appends one entry to `kb/log.md`: a `## [YYYY-MM-DD] <op> | <title>` heading, the body "
"if one is given, and a `---` separator.",
"Not idempotent: every successful run appends a new entry, including a repeated one.",
),
failures=(
cli_contract.Failure(
cause="`--op` is not one of ingest, query, lint, create, update, delete, rename, move",
reaction="Nothing was written - fix the argument and retry once",
),
cli_contract.Failure(
cause="`--body-file` cannot be read",
reaction="Nothing was written - fix the path and retry once",
),
),
examples=(
'tools/wikitool log append --op ingest --title "raw/articles/docker-cheatsheet.md" '
'--body "Created [[Docker]]; updated [[Container]]."',
'tools/wikitool log append --op lint --title "2026-09-26" --body-file lint-summary.md',
),
never=(
"Never re-run after an uncertain outcome without first checking the tail of "
"`kb/log.md` - a second run appends a second entry.",
),
see_also=(
"`wikitool log status` - counts the ingests logged since the last lint",
"`instructions/publish-cycle.md` - where a write session runs this",
),
))
def log_append(
op: str = typer.Option(..., "--op", help="|".join(VALID_OPS)),
title: str = typer.Option(..., "--title", help="Brief description, e.g. a source path"),
body: str = typer.Option("", "--body", help="Optional multi-line details"),
body_file: Optional[Path] = typer.Option(None, "--body-file", help="Read the body from a file instead of --body"),
):
"""Append a formatted entry to `kb/log.md`."""
if op not in VALID_OPS:
fail(f"--op must be one of {VALID_OPS}")
text = body
if body_file:
text = body_file.read_text(encoding="utf-8")
entry = format_log_entry(op, title, text)
with config.LOG_FILE.open("a", encoding="utf-8") as f:
f.write("\n" + entry)
success(f"Appended log entry to {rel_path(config.LOG_FILE)}")
@app.command("status")
@cli_contract.record(cli_contract.CommandRecord(
path="log status",
summary="Read-only: count `ingest` entries logged since the last `lint` entry.",
synopsis=(cli_contract.Variant(usage="log status"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Counts the `ingest` entries in `kb/log.md` after the most recent `lint` entry, or "
"since the start of the log if it was never linted, and the total number of entries.",
"At 10 or more it prints that the `wiki-lint` skill is due next - the every-10-sources "
"full lint of the Maintenance Schedule.",
"Read-only; safe to retry freely.",
),
failures=(cli_contract.Failure(
cause="`kb/log.md` is missing or empty - reported as nothing logged, not a failure",
reaction="",
code=0,
),),
examples=("tools/wikitool log status",),
see_also=(
"`wikitool log append` - writes the entries this counts",
"`wiki-lint` skill - what the threshold asks for",
"`tools/CONTRACT.md` § Maintenance schedule - the cadence this reports on",
),
))
def log_status():
"""Report how many `ingest` operations have been logged since the last
`lint` - the deterministic trigger for the Maintenance Schedule's "every
10 sources" full-lint cadence. Read-only."""
if not config.LOG_FILE.exists():
success("No kb/log.md yet; nothing logged.")
return
entries = parse_log_entries(config.LOG_FILE.read_text(encoding="utf-8"))
count = ingests_since_last_lint(entries)
typer.echo(f"Ingests since last lint: {count}")
if count >= 10:
typer.echo("Threshold reached (>=10) - run the wiki-lint skill (`tools/wikitool lint ...`) next.")
success(f"{len(entries)} total log entries in {rel_path(config.LOG_FILE)}.")