18ae28f918
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.
245 lines
8.8 KiB
Python
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`.")
|