Files
chemenu/tools/chemenu/commands/work_cmd.py
T
torben 18ae28f918
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s
Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki:
raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht
tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren
Grenzen in Code durchgesetzt sind statt im Prompt.

Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige
Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses
Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44
Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist.

Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den
Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er
dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs.

Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte.
Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet -
siehe NOTICE.
2026-09-01 16:26:14 +02:00

245 lines
8.8 KiB
Python

"""`wikitool work` - scaffold and inspect workshop runs under `work/`.
A workshop is the tracked, transient scratch directory for a task that does not
fit in one session (see work/CONTRACT.md). The only mechanical part of it is the
run key: it is derived from the input path, it is the directory name, and a
collision means the same tree is already being ingested. Doing that by hand is
how a second identifier and a `-2` suffix creep in, so it lives here instead.
"""
from __future__ import annotations
import re
import shutil
from typing import Optional
import typer
from chemenu import config
from chemenu.commands._util import fail, rel_path, success, today_iso
app = typer.Typer(help="Workshop runs under work/ (see work/CONTRACT.md).")
RUN_KEY_PREFIX = "ingest-"
# Everything outside this set is folded to a single hyphen, so a run key is
# always a safe directory name and always reproducible from the same input.
_UNSAFE_RE = re.compile(r"[^a-z0-9]+")
REQUIRED_FILES = ("README.md", "plan.md")
def derive_run_key(input_path: str) -> str:
"""Run key for an input path under `raw/`.
Derived from the path *below* `raw/` with separators flattened, never from
the basename: `raw/documents/handbook` and `raw/articles/handbook` share a
basename but are different sources.
"""
relative = input_path.strip().strip("/")
if relative == "raw":
return ""
if relative.startswith("raw/"):
relative = relative[len("raw/"):]
slug = _UNSAFE_RE.sub("-", relative.lower()).strip("-")
if not slug:
return ""
return f"{RUN_KEY_PREFIX}{slug}"
def normalize_run_key(key: str) -> str:
"""Run key for a run with no raw input, given explicitly by the caller.
Not every multi-session task is an ingest. A migration or a sweep across
`kb/` has no input tree to derive a key from, and the alternative - opening
no workshop at all - costs the run its `plan.md`, which is what makes taking
a new session id per unit legitimate rather than a way around a refusal
(instructions/gates.md).
The `ingest-` prefix stays reserved for derived keys, so a directory name
always says which kind of run made it.
"""
slug = _UNSAFE_RE.sub("-", key.strip().lower()).strip("-")
if not slug:
return ""
if slug.startswith(RUN_KEY_PREFIX):
return ""
return slug
def readme_template(run_key: str, input_path: Optional[str]) -> str:
input_line = f"`{input_path}`" if input_path else "none - this run is not an ingest"
return f"""# Workshop: {run_key}
- **Run key:** `{run_key}` (this directory's name - there is no other identifier)
- **Input:** {input_line}
- **Started:** {today_iso()}
- **Session id form:** `WIKITOOL_SESSION_ID="{run_key}/u<N>"`, one per unit
## Goal
TODO: what this run must produce.
## Closes when
TODO: the condition that ends the run - normally "every unit in plan.md is published and
its `## Not Extracted` section is filled".
## Checklist
TODO: one line per unit from plan.md, e.g.
- [ ] u1 <unit> - extract / promote / publish
## Open decisions
- None yet. Record blockers as `DECISION NEEDED: <question>` and stop at them.
"""
def plan_template(run_key: str, input_path: Optional[str]) -> str:
if input_path is None:
return f"""# Plan: {run_key}
Cut the work into units. One unit is one session id and one `publish`, so it has to fit inside
the 60-call iteration budget on its own - count the `wikitool` calls the unit needs before
committing to its size.
| # | Unit | Job | Done when |
|---|------|-----|-----------|
| u1 | TODO | TODO | TODO |
## Deliberately excluded from this run
- TODO: what this run is not touching, and why.
"""
return f"""# Plan: {run_key}
Input tree: `{input_path}`
Cut the tree into units. One unit does one job and becomes one source page. A unit whose
`raw_files` list would pass roughly 15 entries is still too coarse.
| # | Unit (input subtree) | Job | Planned source page | Why this cut |
|---|----------------------|-----|---------------------|--------------|
| u1 | TODO | TODO | `Source - TODO` | TODO |
## Deliberately excluded from this run
- TODO: parts of the tree that are not being ingested at all, and why.
"""
@app.command("new")
def new_command(
input_path: Optional[str] = typer.Option(
None,
"--input",
help="Path to the raw source tree or file this run covers, e.g. raw/documents/handbook",
),
key: Optional[str] = typer.Option(
None,
"--key",
help="Explicit run key for a run with no raw input (a migration, a sweep across kb/). "
"Mutually exclusive with --input; may not start with 'ingest-'.",
),
again: bool = typer.Option(
False,
"--again",
help="This is a deliberate re-ingest of a tree already processed before: append today's "
"date to the run key instead of refusing the collision.",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be created, write nothing"),
):
"""Scaffold `work/<runkey>/` for one workshop run."""
if (input_path is None) == (key is None):
fail(
"Pass exactly one of --input (an ingest of raw material, key derived from the path) "
"or --key (a run with no raw input, key given explicitly)."
)
if key is not None:
run_key = normalize_run_key(key)
if not run_key:
fail(
f"`{key}` is not a usable run key - it must contain letters or digits and must not "
f"start with `{RUN_KEY_PREFIX}`, which is reserved for keys derived from a raw path."
)
else:
source = (config.ROOT / input_path).resolve()
try:
source.relative_to(config.RAW_DIR)
except ValueError:
fail(
f"`{input_path}` is not under raw/. A run key is derived from the input path below "
"raw/, so an --input workshop can only be opened for raw material. A run with no raw "
"input takes --key instead."
)
if not source.exists():
fail(f"`{input_path}` does not exist - a workshop is opened for material that is already in raw/")
run_key = derive_run_key(input_path)
if not run_key:
fail(f"`{input_path}` yields an empty run key - point --input at a subtree of raw/, not at raw/ itself")
if again:
run_key = f"{run_key}-{today_iso()}"
target = config.WORK_DIR / run_key
if target.exists():
if again:
fail(
f"`{rel_path(target)}` already exists - a second re-ingest of the same tree on the "
"same day. Resume that run or close it first."
)
fail(
f"`{rel_path(target)}` already exists, which means this tree is already being ingested. "
"Resume that run, or - if the tree itself has changed since - re-run with --again to "
"open a dated second pass. Never work around this with a numbered suffix."
)
if dry_run:
success(f"Would create {rel_path(target)}/ with {', '.join(REQUIRED_FILES)}")
return
target.mkdir(parents=True)
(target / "README.md").write_text(readme_template(run_key, input_path), encoding="utf-8")
(target / "plan.md").write_text(plan_template(run_key, input_path), encoding="utf-8")
typer.echo(f"Run key: {run_key}")
typer.echo(f"Workshop: {rel_path(target)}/")
typer.echo(f"Next: fill in plan.md, then export WIKITOOL_SESSION_ID=\"{run_key}/u1\"")
success(f"Created workshop {run_key}")
@app.command("close")
def close_command(
run_key: str = typer.Option(..., "--run-key", help="The workshop directory name"),
yes: bool = typer.Option(
False,
"--yes",
"-y",
help="Confirm deletion. Required: closing discards the only copy of the run's working "
"notes, so the durable conclusions must already be in kb/.",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted, delete nothing"),
):
"""Delete a finished workshop. Its conclusions must already be in `kb/`."""
target = config.WORK_DIR / run_key
if not target.is_dir():
fail(f"No workshop `{run_key}` under work/ - `ls work/` shows the open ones")
files = sorted(p for p in target.rglob("*") if p.is_file())
if dry_run or not yes:
listing = "\n".join(f"- {rel_path(p)}" for p in files)
message = (
f"Closing `{run_key}` deletes {len(files)} file(s):\n{listing}\n"
"Nothing here is recoverable from the rest of the repo. Confirm the durable "
"conclusions are already in kb/, then re-run with --yes."
)
if dry_run:
typer.echo(message)
return
fail(message)
shutil.rmtree(target)
success(f"Closed workshop {run_key} ({len(files)} file(s) deleted). Log it with `log append`.")