Files
chemenu/tools/chemenu/commands/doctor.py
T
torbenandClaude Opus 5.5 ecefe0a033
CI / verify (push) Successful in 8m19s
CI / pwsh (push) Successful in 2m37s
Release / release (push) Successful in 33s
Release / wiki-mirror (push) Skipped
gates: AGENTS.md § Gates and mcp-read-server name every intake channel behind the Upload Review Gate, not only submit (#213 close); 10.0.0-beta.12
Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- docs/reference/configuration.md
- instructions/mcp-read-server.md
- tools/chemenu/commands/doctor.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-12 22:26:04 +02:00

1188 lines
55 KiB
Python

"""`wikitool doctor` - one deterministic health check for a wiki instance.
Read-only, never writes. Exists to back `instructions/setup-instance.md` (and
any other instance-setup procedure) with a single command instead of ten
individual checks spelled out in prose - the same reasoning that keeps
mechanical work in code everywhere else in this repo. Each check reports
`OK`, `WARN`, or `FAIL` plus, on anything but `OK`, the command to fix it.
Only a `FAIL` makes the overall exit code non-zero: a fresh instance with no
remote yet, or no `WIKITOOL_SESSION_ID` set, is a valid state, not a fault.
"""
from __future__ import annotations
import json as _json
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from chemenu import cli_contract, config, config_shapes, conventions, filters, kb_collections, legacy_settings, prerequisites, toolpaths, version as version_mod
from chemenu.commands import instructions_cmd
from chemenu.errors import ChemenuError
from chemenu.commands._util import rel_path
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
from chemenu.session import session_id_source as _session_id_source
console = Console()
@dataclass
class Check:
name: str
status: str # "OK" | "WARN" | "FAIL"
detail: str
fix: Optional[str] = None
def _git(args: list[str]) -> Optional[subprocess.CompletedProcess]:
try:
return subprocess.run(
[toolpaths.git(), *args], cwd=config.ROOT, capture_output=True, text=True, encoding="utf-8", timeout=5
)
except (OSError, subprocess.SubprocessError, toolpaths.ToolPathError):
# A broken tool-paths file is reported once, by `check_tool_paths` -
# not as a crash from every check that happens to need git.
return None
PREFLIGHT_FIX = f"Run tools/preflight.sh (PowerShell 7: {toolpaths.PREFLIGHT_PWSH})"
BACKUP_HOW_TO = "docs/how-to/back-up-to-a-synced-folder.md"
def check_python() -> Check:
version = sys.version_info
if version < (3, 11):
return Check(
"python", "FAIL", f"Python {version.major}.{version.minor} found, need >= 3.11",
f"Install Python 3.11+, then: {PREFLIGHT_FIX}",
)
return Check("python", "OK", f"Python {version.major}.{version.minor}.{version.micro}")
def check_ripgrep() -> Check:
try:
rg = toolpaths.rg()
except toolpaths.ToolPathError as exc:
return Check("ripgrep", "FAIL", str(exc), PREFLIGHT_FIX)
if shutil.which(rg):
where = "on PATH" if rg == "rg" else f"at {rg}"
return Check("ripgrep", "OK", f"rg found {where}")
return Check(
"ripgrep", "FAIL", "rg not found - `search` and `sources coverage` need it",
f"Install ripgrep (e.g. `apt install ripgrep` / `brew install ripgrep`), then: {PREFLIGHT_FIX}",
)
def check_import_filters() -> list[Check]:
"""One line per `raw derive` filter that needs a package: a missing one costs only its
own format, so it is a WARN, never a FAIL."""
found = []
for candidate in filters.all_filters():
if not candidate.requires:
continue
missing = candidate.missing()
formats = ", ".join(candidate.extensions)
if missing:
found.append(Check(
f"import-filter-{candidate.name}", "WARN",
f"{', '.join(missing)} missing - raw derive cannot read {formats} until it is installed",
PREFLIGHT_FIX,
))
else:
found.append(Check(f"import-filter-{candidate.name}", "OK", f"{candidate.engine().split(' + ', 1)[1]} reads {formats}"))
return found
def check_tool_paths() -> Check:
"""`.wikitool-tools.json`: written by a preflight that finished, and every
path in it still there. The launcher refuses to start without a complete
file, so a FAIL here is mostly a path that went away since - an uninstalled
or moved tool."""
name = toolpaths.FILE_NAME
try:
data = toolpaths.load()
except toolpaths.ToolPathError as exc:
return Check("tool-paths", "FAIL", str(exc), PREFLIGHT_FIX)
if data is None:
return Check(
"tool-paths", "FAIL", f"{name} is missing - the preflight has not run in this checkout",
PREFLIGHT_FIX,
)
if data.get("complete") is not True:
return Check(
"tool-paths", "FAIL", f"{name} is incomplete - the last preflight run stopped before the end",
PREFLIGHT_FIX,
)
recorded = data["tools"]
needed = [tool.name for tool in prerequisites.load_manifest().tools_for(prerequisites.platform())]
unrecorded = [tool for tool in needed if not recorded.get(tool)]
gone = [f"{tool} ({path})" for tool, path in sorted(recorded.items())
if not (isinstance(path, str) and os.path.isfile(path))]
problems = []
if unrecorded:
problems.append("not recorded: " + ", ".join(unrecorded))
if gone:
problems.append("no longer there: " + ", ".join(gone))
if problems:
return Check("tool-paths", "FAIL", f"{name}: " + "; ".join(problems), PREFLIGHT_FIX)
return Check("tool-paths", "OK", f"{name}: " + ", ".join(sorted(recorded)) + " recorded and present")
def check_install_dir() -> Check:
"""The install folder against Windows' MAX_PATH, the same limit the
preflight enforces before it unpacks anything."""
problem = prerequisites.install_dir_problem()
if problem is None:
if prerequisites.platform() != "windows":
detail = "no folder length limit on this platform"
elif prerequisites.long_paths_enabled():
detail = "Windows long paths are on - no folder length limit"
else:
detail = "fits Windows' path limit"
return Check("install-dir", "OK", detail)
return Check(
"install-dir", "FAIL", problem[:1].upper() + problem[1:],
"Move the wiki to a shorter folder (for example C:\\Chemenu) and run the preflight there, "
"or have someone with administrator rights turn on long paths in Windows",
)
def check_execution_policy() -> Check:
"""Whether an ordinary PowerShell 7 may run `tools/wikitool.ps1` - the policy
the preflight checks before it stops, so a harness that starts `pwsh`
without a bypass is not turned away by a setting nobody looked at."""
if prerequisites.platform() != "windows":
return Check("execution-policy", "OK", "only PowerShell on Windows has one - not applicable here")
policy = prerequisites.execution_policy()
if policy is None:
return Check(
"execution-policy", "WARN", "the PowerShell execution policy could not be read",
f"{PREFLIGHT_FIX}; the preflight checks it",
)
if not policy.blocks:
return Check("execution-policy", "OK", policy.describe())
if policy.group_policy:
return Check(
"execution-policy", "FAIL",
f"a group policy sets the PowerShell execution policy to {policy.policy} - "
"tools/wikitool.ps1 does not start",
"A group policy cannot be overridden from this computer: ask whoever looks after it to allow "
"locally written scripts (RemoteSigned) for PowerShell 7, or run `tools/wikitool` from Git Bash",
)
return Check(
"execution-policy", "FAIL",
f"the PowerShell execution policy is {policy.describe()} - tools/wikitool.ps1 does not start",
"In a PowerShell 7 window: Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned",
)
def check_script_marks() -> Check:
"""Mark of the Web on the stack's PowerShell scripts: a wiki downloaded with a
browser and unpacked in Explorer carries one, and PowerShell refuses to run
a marked script under its usual policy."""
if prerequisites.platform() != "windows":
return Check("script-marks", "OK", "no Mark of the Web outside Windows")
marked = prerequisites.marked_scripts()
if not marked:
return Check("script-marks", "OK", "no script under tools/ is marked as downloaded")
shown = ", ".join(marked[:5]) + (", ..." if len(marked) > 5 else "")
return Check(
"script-marks", "FAIL",
f"Windows marks scripts under tools/ as downloaded from the internet: {shown}",
"In a PowerShell 7 window, from the folder holding tools/: "
"Get-ChildItem -Recurse -File | Unblock-File",
)
def check_author() -> Check:
try:
author = config.default_author()
except toolpaths.ToolPathError as exc:
return Check("author", "FAIL", f"`git config user.name` cannot be asked: {exc}", PREFLIGHT_FIX)
if author is None:
return Check(
"author", "FAIL", "Neither $WIKI_AUTHOR nor `git config user.name` resolves",
"Run `git config user.name \"<Your Name>\"`, or export WIKI_AUTHOR",
)
import os
source = "WIKI_AUTHOR" if os.environ.get("WIKI_AUTHOR", "").strip() else "git config user.name"
return Check("author", "OK", f"'{author}' (from {source})")
def check_git_repo() -> list[Check]:
checks: list[Check] = []
inside = _git(["rev-parse", "--is-inside-work-tree"])
if inside is None or inside.returncode != 0 or inside.stdout.strip() != "true":
checks.append(
Check(
"git-repo", "FAIL", "Not inside a git working tree",
"Run `git init -b main`",
)
)
return checks
checks.append(Check("git-repo", "OK", "Inside a git working tree"))
name = _git(["config", "user.name"])
email = _git(["config", "user.email"])
if not name or not name.stdout.strip():
checks.append(
Check("git-identity", "FAIL", "`git config user.name` is not set",
"Run `git config user.name \"<Your Name>\"`")
)
elif not email or not email.stdout.strip():
checks.append(
Check("git-identity", "FAIL", "`git config user.email` is not set",
"Run `git config user.email \"<you@example.com>\"`")
)
else:
checks.append(Check("git-identity", "OK", f"{name.stdout.strip()} <{email.stdout.strip()}>"))
branch = _git(["rev-parse", "--abbrev-ref", "HEAD"])
branch_name = branch.stdout.strip() if branch and branch.returncode == 0 else ""
if not branch_name or branch_name == "HEAD":
checks.append(
Check("git-branch", "WARN", "No commit yet, or detached HEAD",
"Make the first commit via `publish` once ready")
)
else:
checks.append(Check("git-branch", "OK", f"On branch '{branch_name}'"))
remote = _git(["remote", "get-url", "origin"])
if remote and remote.returncode == 0 and remote.stdout.strip():
checks.append(Check("git-remote", "OK", remote.stdout.strip()))
else:
checks.append(
Check(
"git-remote", "WARN", "No 'origin' remote configured",
"A local-only instance is valid - `git remote add origin <url>` if you want one. "
"Every `publish` needs --no-push until then. Without a git server, a synced "
f"folder can serve: {BACKUP_HOW_TO}",
)
)
return checks
def check_skills() -> Check:
sources = instructions_cmd.skill_dirs()
if not sources:
return Check("skills", "FAIL", "No skills found under instructions/", None)
target_dirs = instructions_cmd.target_dirs()
missing = 0
drifted: list[str] = []
for target_root in target_dirs:
for source in sources:
difference = instructions_cmd.drift(source, target_root / source.name)
if difference == "missing":
missing += 1
elif difference:
drifted.append(f"{rel_path(target_root / source.name)}: {difference}")
expected = len(sources) * len(target_dirs)
if missing == expected and not drifted:
return Check(
"skills", "FAIL", "No skills published yet",
"Run `tools/wikitool instructions sync`",
)
if drifted:
return Check(
"skills", "FAIL",
f"{len(drifted)} published copy/copies drifted from source, first: {drifted[0]}",
"Run `tools/wikitool instructions sync`",
)
return Check("skills", "OK", f"{expected} published copy/copies match their source")
def check_structure() -> Check:
missing = []
for relative_path in (
"kb/CONTRACT.md", "raw/CONTRACT.md", "reports/CONTRACT.md",
"work/CONTRACT.md", "sites/CONTRACT.md", "instructions/CONTRACT.md", "types/type-spec.md",
):
if not (config.ROOT / relative_path).exists():
missing.append(relative_path)
collections = kb_collections.iter_kb_collections()
if not collections:
missing.append("kb/*/COLLECTION.md")
if missing:
return Check(
"structure", "FAIL", f"Missing: {', '.join(missing)}",
"Re-run `dist export`, or restore the missing contract(s) from the source repo",
)
return Check(
"structure", "OK", f"{len(collections)} collection(s), all stage contracts present"
)
def check_personalization() -> Check:
"""Whether this instance knows who it works for, and how it sounds.
`USER.md` and `SOUL.md` are read every session, so an instance without
them runs a generic agent against a wiki built for one person - which is
a fault, not a preference, hence `FAIL` rather than `WARN`. They are also
the one pair of required files a distribution cannot ship filled: their
content is personal, so `dist export` carries the templates and the
Personalization step of `setup-instance.md` writes the real ones. That
makes a still-templated file the second failure mode worth naming
separately - it looks present and answers nothing.
"""
missing: list[str] = []
unfilled: list[str] = []
for name in config.PERSONALIZATION_FILES:
path = config.ROOT / name
if not path.is_file():
missing.append(name)
elif config.TEMPLATE_SENTINEL in path.read_text(encoding="utf-8"):
unfilled.append(name)
fix = (
"Run the Personalization step of instructions/setup-instance.md - it interviews you "
f"along {' and '.join(config.PERSONALIZATION_TEMPLATES)} and writes your answers verbatim"
)
if missing:
return Check("personalization", "FAIL", f"Missing: {', '.join(missing)}", fix)
if unfilled:
return Check(
"personalization", "FAIL",
f"Still the unfilled template: {', '.join(unfilled)}", fix,
)
return Check("personalization", "OK", f"{', '.join(config.PERSONALIZATION_FILES)} present and filled")
def check_conventions() -> Check:
"""Whether this instance has said how its own pages are written.
`kb/CONVENTIONS.md` carries the decisions `kb/CONTRACT.md` deliberately no
longer makes: the KB language and the headings its two generated regions
render under, the tone examples, the hedging rule, the naming forms.
`FAIL` rather than `WARN` because those decisions bind every page, and
because it has the same two failure modes the personalization pair has: the
distribution can ship the template but never the filled file, so a template
renamed and left unanswered looks present and decides nothing.
The headings themselves are only cosmetic now - the marker pair carries each
region's identity, so a default renders wrong words rather than corrupting
structure. That is why this check is about the *file*, not about rescuing a
lookup the compiler can no longer get wrong.
"""
path = conventions.conventions_file()
fix = (
"Copy kb/CONVENTIONS.md.template to kb/CONVENTIONS.md and answer it - the KB-language "
"step of instructions/setup-instance.md walks it, and instructions/kb-profiles.md has "
"the ready-made profiles to adopt"
)
if not path.is_file():
return Check(
"conventions", "FAIL",
f"kb/{conventions.CONVENTIONS_FILENAME} is missing - this instance has not "
"declared how its pages are written",
fix,
)
issues = conventions.declaration_issues()
if issues:
return Check("conventions", "FAIL", "; ".join(issues), fix)
declared = conventions.language() or "unspecified"
from chemenu import blocks
headings = ", ".join(conventions.heading(block) for block in blocks.BLOCKS)
return Check(
"conventions", "OK",
f"kb/{conventions.CONVENTIONS_FILENAME} present, language {declared}, "
f"sections {headings}",
)
def check_environment() -> Check:
"""Whether this checkout records the environment it works through.
`ENVIRONMENT.md` names the harness, the published skills, the MCP
servers, the connectors and the git remotes this working copy actually
uses. Missing it costs a session some questions, not correctness, so this
check never FAILs - the whole point of the file is that it is optional,
and a FAIL would make it mandatory by the back door.
The one thing worth reporting is the failure mode the personalization
check already knows: a template renamed but not filled in. That file is
present, is loaded into every session, and answers nothing - worse than
absence, because absence is honest.
"""
path = config.ROOT / config.ENVIRONMENT_FILE
if not path.is_file():
return Check(
"environment", "OK", f"{config.ENVIRONMENT_FILE} absent (optional)",
)
if config.TEMPLATE_SENTINEL in path.read_text(encoding="utf-8"):
return Check(
"environment", "WARN",
f"{config.ENVIRONMENT_FILE} is still the unfilled template",
f"Fill it in along {config.ENVIRONMENT_TEMPLATE}'s sections and drop the "
f"`{config.TEMPLATE_SENTINEL}` line, or delete the file - it is optional",
)
return Check("environment", "OK", f"{config.ENVIRONMENT_FILE} present and filled")
def check_legacy_settings() -> list[Check]:
"""One FAIL per settings file of an earlier version that is still there.
Nothing reads those files any more, so a leftover is a setting that is not
in effect - for the Publish-Remote Gate and the upload limits that means a
safeguard silently off, which is why this is a FAIL and not a note."""
return [
Check(
"legacy-settings", "FAIL",
f"{name} is a settings file of an earlier version and no longer read",
legacy_settings.MIGRATE_COMMAND,
)
for name in legacy_settings.present(config.ROOT)
]
def check_publish_remotes() -> Check:
"""Whether the Publish-Remote Gate is armed in this checkout.
Absent is a legitimate state, not a fault: a checkout with a single remote
and nothing private in it has nothing to protect, and making the file
mandatory would turn a safeguard into paperwork. So an absent allowlist never
FAILs - it reports, the way `environment` does. An unreadable one does.
It does WARN for the case that actually bites: more than one remote
configured and no allowlist. That is the shape a private instance has once
it adds a public remote, and it is exactly when a wrong `--remote`
stops being a typo and starts being a disclosure.
Both absent states say **armed** or **not armed** rather than only naming
the file. AGENTS.md lists this among the limits enforced in code, so a
line that reports the file's absence and leaves the reader to infer what
that means about the gate is how a checkout ends up trusting a safeguard
that is not running - which is worse than having none.
"""
from chemenu import settings
try:
section = settings.load(only=(config_shapes.REMOTES.key,)).section(config_shapes.REMOTES.key)
urls = list(section["allowed_push_urls"]) if section.configured else None
except ChemenuError as exc:
return Check(
"publish-remotes", "FAIL", str(exc),
"Fix the settings as the message says - a broken allowlist is not 'no allowlist'",
)
if urls is not None:
return Check(
"publish-remotes", "OK",
f"Gate armed: {len(urls)} allowed push target(s) in remotes.allowed_push_urls "
f"({config.LOCAL_SETTINGS_FILENAME})",
)
result = _git(["remote"])
remotes = [r for r in result.stdout.split() if r] if result is not None else []
if len(remotes) > 1:
return Check(
"publish-remotes", "WARN",
f"Gate not armed: {len(remotes)} remotes ({', '.join(remotes)}) and no "
"remotes.allowed_push_urls - every one of them is a legal publish target",
f"Put the push URL this checkout may publish to into {config.LOCAL_SETTINGS_FILENAME} "
"(section remotes, key allowed_push_urls) - see instructions/gates.md",
)
return Check(
"publish-remotes", "OK",
"Gate not armed: no remotes.allowed_push_urls - any push target passes "
"(1 remote configured, nothing to confuse it with)",
)
def check_sync_folder() -> Check:
"""Whether the git directory lies in a folder a sync client uploads. Never a
`FAIL`: the instance works there, until the sync client locks, offloads or
conflict-copies a file under `.git` - which is what the `WARN` is for."""
from chemenu import backup
result = _git(["rev-parse", "--absolute-git-dir"])
if result is None or result.returncode != 0 or not result.stdout.strip():
return Check("sync-folder", "OK", "no git directory to check")
git_dir = result.stdout.strip()
synced = backup.synced_folder_of(Path(git_dir))
if synced is None:
return Check("sync-folder", "OK", f"{git_dir} lies outside every synced folder this machine names")
return Check(
"sync-folder", "WARN",
f"{git_dir} lies in the synced folder {synced} - a sync client locks files under .git for "
"a moment (which breaks `git gc`), offloads them as placeholders, and makes conflict "
"copies once a second device syncs it",
f"Move the instance out of the synced folder and back it up into it instead: {BACKUP_HOW_TO}. "
"On Windows, check whether Documents or Desktop is redirected into OneDrive (Known Folder "
"Move) - an instance there lies in OneDrive without looking like it",
)
def check_backup() -> Check:
"""The `backup` section: absent is `OK` (no backup configured), malformed is
a `FAIL`, and a configured one whose target or `origin` is not set up, or
whose machine data was never backed up, is a `WARN`."""
from chemenu import backup
try:
cfg = backup.read_config()
except ChemenuError as exc:
return Check(
"backup", "FAIL", str(exc),
"Fix the backup section as the message says - a broken one is not 'no backup'",
)
if cfg is None:
return Check("backup", "OK", "absent - no backup section in the settings")
problems, details = [], [f"target {cfg.target}"]
if not backup.is_bare_repo(cfg.git_dir):
problems.append(f"{cfg.git_dir} is not a bare repository")
else:
url = backup.push_url(config.ROOT)
if url is None or not backup.same_location(url, cfg.git_dir, config.ROOT):
problems.append(f"origin pushes to {url or 'nothing'}, not to {cfg.git_dir}")
if cfg.machine_data:
try:
manifest = backup.read_manifest(cfg.machine_dir)
except ChemenuError as exc:
manifest = None
problems.append(str(exc))
else:
if manifest is None:
problems.append(f"no machine-data backup in {cfg.machine_dir} yet")
else:
details.append(
f"last machine-data backup {backup.age_text(manifest.get('created'))} "
f"({len(manifest['files'])} file(s) in {cfg.machine_dir})"
)
else:
details.append("machine data not backed up (machine_data is false)")
if problems:
return Check(
"backup", "WARN", "; ".join([*details, *problems]),
f"Run `tools/wikitool backup init`, then `publish` or `tools/wikitool backup run` - "
f"see {BACKUP_HOW_TO}",
)
return Check("backup", "OK", "; ".join(details))
def check_generated_files() -> Check:
missing = [
rel_path(path)
for path in (config.INDEX_FILE, config.LOG_FILE, config.PROVENANCE_FILE)
if not path.exists()
]
if missing:
return Check(
"generated-files", "FAIL", f"Missing: {', '.join(missing)}",
"Run `index rebuild` and `sources rebuild-index`",
)
return Check("generated-files", "OK", "kb/index.md, kb/log.md, kb/provenance.md present")
def check_telemetry() -> Check:
"""Whether tracing is on for this checkout, why, and how full its two caps
are.
Never `FAIL`s, the same line `check_publish_remotes` and
`check_environment` draw: both an enabled and a disabled tree are
legitimate states, and a `FAIL` would make one of them mandatory by the
back door. It `WARN`s on a `telemetry` section of `.wikitool-local.json` the policy
does not read as written, since the policy itself drops that in silence. The tree walk below is fine here - `doctor` is not a hot path,
unlike the `stat` `emit()` does on every append.
"""
from chemenu.telemetry import policy as telemetry_policy
from chemenu.telemetry import reader
from chemenu.telemetry.writer import TRACE_FILE, trace_root
pol = telemetry_policy.resolve(config.ROOT)
root = trace_root()
session_count = len(reader.sessions(root))
total_bytes = (
sum(f.stat().st_size for f in root.glob(f"*/{TRACE_FILE}") if f.is_file())
if root.exists()
else 0
)
state = "on" if pol.enabled else "off"
detail = (
f"{state} ({pol.reason}); {session_count}/{pol.keep_sessions} session(s), "
f"{total_bytes:,} byte(s) under {rel_path(root)} "
f"(cap {pol.max_session_bytes:,} byte(s)/session)"
)
deviations = _telemetry_file_deviations()
if deviations:
return Check(
"telemetry", "WARN",
f"{detail}. The telemetry section of {config.LOCAL_SETTINGS_FILENAME} is not read as "
f"written: {'; '.join(deviations)}",
"Correct the section to the shape in docs/reference/configuration.md#telemetry, "
"or remove it to get the default",
)
return Check("telemetry", "OK", detail)
def _telemetry_file_deviations() -> list[str]:
"""What the telemetry policy silently drops or reinterprets in the
`telemetry` section of this checkout's `.wikitool-local.json` - nothing when
the file or the section is absent.
The policy has to stay silent (a hook handler reads it on every tool
call); this is where the silence becomes visible."""
path = config.ROOT / config.LOCAL_SETTINGS_FILENAME
if not path.is_file():
return []
try:
data = _json.loads(path.read_text(encoding="utf-8"))
except (OSError, _json.JSONDecodeError) as exc:
return [f"{config.LOCAL_SETTINGS_FILENAME} is unreadable ({exc}), so the section is ignored"]
if not isinstance(data, dict) or config_shapes.TELEMETRY.key not in data:
return []
return config_shapes.deviations(config_shapes.TELEMETRY, data[config_shapes.TELEMETRY.key])
def check_upload_intake() -> Check:
"""Which intake channels this checkout has, how many submissions wait in
the quarantine, and whether the quarantine of earlier versions
(`mcp-upload/`) still holds any.
Absent is the *safe* default here, unlike `check_publish_remotes`'s "any
push target passes" absence: no `intake` section means no channel and no
`submit` tool, not that anything is unrestricted - so this never `FAIL`s on
a missing section. It does `FAIL` on one that parses to something invalid,
because a broken opt-in must not silently disable the very limits it exists
to enforce, and on an `mcp-upload/` with content, which no command reads
any more.
"""
from chemenu import upload as upload_module
legacy = upload_module.legacy_dir_problem(config.ROOT)
if legacy:
return Check("upload-intake", "FAIL", legacy, "Move the directory as the message says")
try:
channels = upload_module.read_channels(config.ROOT)
except ChemenuError as exc:
return Check(
"upload-intake", "FAIL", str(exc),
"Fix the intake section as the message says - a broken one is not treated as "
"'no channel'",
)
if not channels:
return Check(
"upload-intake", "OK",
"no channel - no intake section in the settings, so no submit tool and nothing to collect",
)
pending = upload_module.list_submissions(config.ROOT)
described = ", ".join(f"{channel.name} ({channel.handler})" for channel in channels.values())
detail = f"channels: {described}; {len(pending)} submission(s) waiting in {rel_path(config.UPLOAD_DIR)}"
missing = [channel for channel in channels.values()
if channel.handler == "folder" and not (channel.path and channel.path.is_dir())]
if missing:
names = ", ".join(f"{channel.name} ({channel.path})" for channel in missing)
return Check(
"upload-intake", "WARN", f"{detail}; the folder does not exist: {names}",
"Create the folder or correct `path` of the channel in .wikitool-local.json",
)
return Check("upload-intake", "OK", detail)
def check_tasks_provider() -> Check:
"""Whether a task-tracker provider is configured for the GTD review
(Gitea #124), and whether it looks reachable.
Absent is `OK`, the same posture `check_upload_intake` takes on its own
config file: an instance with no tracker configured is legitimate, it
just cannot run the weekly review (#125) yet. A malformed config is a
`FAIL` for the same reason a malformed upload config is - it decides
which provider real credentials flow to, so a broken one must not read as
"nothing configured". Provider reachability itself never affects the
exit code, same as `check_git_repo`'s remote check: the app being closed
is normal, not a fault.
For `superproductivity`, only the instance's configured `access` path is
ever attempted (Gitea #133) - `api` reports API reachability, `snapshot`
reports whether a backup file is ready; the other path is simply not a
finding, since this instance never touches it. `caldav` (Gitea #139) has
only one access mode - it always reports reachability and authentication
against the server, the same never-FAIL posture.
"""
from chemenu import config
from chemenu.errors import ValidationError
from chemenu.tasks import config as tasks_config
try:
cfg = tasks_config.read_config(config.ROOT)
except ChemenuError as exc:
return Check(
"tasks-provider", "FAIL", str(exc),
"Fix the tasks, superproductivity and caldav sections as the message says - a "
"broken one is not treated as 'no tracker configured'",
)
if cfg is None:
return Check(
"tasks-provider", "OK",
"No tasks section in the settings - no task tracker configured (the weekly "
"review needs one, everything else does not)",
)
override = config.tasks_config_override()
via = f" [config: {override} via {config.ENV_TASKS_CONFIG}]" if override is not None else ""
if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp
try:
sp_cfg = sp.SuperProductivityConfig.from_section(cfg.provider_config)
except ValidationError as exc:
return Check(
"tasks-provider", "FAIL", str(exc),
"Fix the 'superproductivity' section of the settings",
)
# Only the configured access path is a finding (Gitea #133) - the
# other one is not attempted at all, so it has nothing to report.
if sp_cfg.access == sp.ACCESS_API:
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
return Check(
"tasks-provider", "OK", f"superproductivity: access=api; {api_state}{via}",
)
try:
snapshot_path = sp.latest_snapshot_path(sp_cfg)
read_state = f"read path OK, newest snapshot {rel_path(snapshot_path)}"
except ValidationError as exc:
read_state = f"read path not ready ({exc})"
return Check(
"tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}{via}",
)
if cfg.provider == "caldav":
from chemenu.tasks import caldav as cd
try:
cd_cfg = cd.CalDAVConfig.from_section(cfg.provider_config)
except ValidationError as exc:
return Check(
"tasks-provider", "FAIL", str(exc),
"Fix the 'caldav' section of the settings",
)
state = cd.probe(cd_cfg)
return Check("tasks-provider", "OK", f"caldav: {state}{via}")
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured{via}")
def _settings_file_check(name: str, path, in_instance: bool) -> tuple[Check, Optional[dict], bool]:
"""One settings file on its own: absent, unreadable, or read. The third
value says whether the file is usable for the joint check."""
from chemenu import settings
try:
data = settings.read_file(path)
except Exception as exc: # ValidationError, and anything else a broken file raises
return Check(name, "FAIL", str(exc), f"Fix or delete {path.name}"), None, False
if data is None:
what = ("no instance settings" if in_instance else "no settings for this checkout")
return Check(name, "OK", f"{path.name} absent - {what}"), None, True
return Check(name, "OK", f"{path.name} present"), data, True
def check_settings() -> list[Check]:
"""The tiered settings, one line per tier: `wikitool.json` (instance),
`.wikitool-local.json` (checkout), and the references both carry (secret).
A reference is reported as resolvable or not, never with its value. One
1Password cannot open without a human - locked, signed out, waiting for an
approval - is a `WARN`: nothing is wrong with the configuration, and a
`FAIL` would make `doctor` red whenever the app is locked."""
from chemenu import settings
from chemenu.errors import ValidationError
instance_check, instance_data, instance_ok = _settings_file_check(
"settings-instance", settings.instance_path(), True)
local_check, local_data, local_ok = _settings_file_check(
"settings-checkout", settings.local_path(), False)
if not (instance_ok and local_ok):
return [instance_check, local_check,
Check("settings-secrets", "WARN", "not checked - a settings file is unreadable",
"See the settings line above")]
if instance_data is None and local_data is None:
return [instance_check, local_check, Check("settings-secrets", "OK", "no references")]
try:
found = settings.check_files(instance_data, local_data)
except ValidationError as exc:
# A problem names the local file when it is that file's; everything
# else - the committed file's own, a value set in both, a required
# field nobody set - is reported on the instance line.
local_prefix = f"{config.LOCAL_SETTINGS_FILENAME}: "
problems = str(exc).splitlines()
for check, own in ((local_check, [p for p in problems if p.startswith(local_prefix)]),
(instance_check, [p for p in problems if not p.startswith(local_prefix)])):
if own:
check.status, check.detail = "FAIL", "; ".join(own)
check.fix = ("Fix the file as the message says - `wikitool config set` writes a "
"value where its tier puts it")
return [instance_check, local_check,
Check("settings-secrets", "WARN", "not checked - the settings files do not pass",
"See the settings lines above")]
for check, data, file_name in ((instance_check, instance_data, config.INSTANCE_SETTINGS_FILENAME),
(local_check, local_data, config.LOCAL_SETTINGS_FILENAME)):
if data is None:
continue
known = sorted(k for k in data if k not in ("schema", settings.INSTANCE_ID_KEY)
and k not in found.unknown.get(file_name, []))
check.detail = f"{file_name} present, sections: {', '.join(known) or 'none'}"
if file_name == config.INSTANCE_SETTINGS_FILENAME and found.instance_id:
check.detail += f"; instance id {found.instance_id}"
unknown = found.unknown.get(file_name)
if unknown:
check.status = "WARN"
check.detail += f"; not read by this version: {', '.join(unknown)}"
check.fix = "A newer stack may write these; otherwise remove them"
refs = list(found.refs())
if not refs:
return [instance_check, local_check, Check("settings-secrets", "OK", "no references")]
resolvable, unresolved, locked = [], [], []
for key, ref in refs:
try:
settings.resolve(ref, key=key, instance_id=found.instance_id)
except settings.SecretSourceLocked:
locked.append(key)
except ValidationError as exc:
unresolved.append(str(exc))
else:
resolvable.append(key)
summary = f"{len(resolvable)} of {len(refs)} reference(s) resolvable"
if unresolved:
return [instance_check, local_check, Check(
"settings-secrets", "FAIL", summary + "; not resolvable: " + "; ".join(unresolved),
"Provide each value where its message says - by the user, never by `config set`",
)]
if locked:
return [instance_check, local_check, Check(
"settings-secrets", "WARN",
summary + f"; 1Password would not answer for: {', '.join(locked)}",
"Unlock the 1Password app or run `op signin`, then run doctor again",
)]
return [instance_check, local_check, Check("settings-secrets", "OK", summary)]
def check_site_handlers() -> Check:
from chemenu import sites
instance, broken = sites.read_instance()
if broken:
return Check(
"site-handlers", "FAIL",
"; ".join(f"{b.file}: {b.error}" for b in broken)
+ " - `raw fetch` refuses a URL of the host such a file names (or any URL, if no host can be read)",
"Fix or remove the file named; `sites/CONTRACT.md` has the format",
)
if not instance:
return Check("site-handlers", "OK", "no handler files in sites/")
replaced = {mine.name: theirs.name for mine, theirs in sites.replacements(instance)}
detail = ", ".join(
f"{site.name} (replaces the stack's {replaced[site.name]})" if site.name in replaced else site.name
for site in instance
)
return Check("site-handlers", "OK", f"{len(instance)} handler(s) of this instance: {detail}")
def check_browser() -> Check:
"""The optional browser component: OK whether or not it is installed, a
WARN only when this instance has something that needs it - a host routed
through the session (`raw login`) or a handler that declares `render`.
Never a FAIL: every other command runs without it."""
from chemenu import browser, settings, sites
why = browser.missing()
if why is None:
return Check("browser", "OK", f"available - Playwright in the venv, its browser in {rel_path(browser.browsers_path())}")
try:
routes = settings.load(only=("fetch",)).section("fetch")["routes"]
except ChemenuError:
routes = {} # a broken section is reported under settings-*
sessions = sorted(host for host, via in routes.items() if via == config_shapes.SESSION_ROUTE)
instance, _ = sites.read_instance()
rendered = sorted(site.name for site in sites.catalogue(instance) if site.render)
needs = ([f"`raw login` for {', '.join(sessions)}"] if sessions else []) + (
[f"the handler(s) {', '.join(rendered)}, which declare `render`"] if rendered else [])
if needs:
return Check("browser", "WARN", f"not available ({why}) - needed by {' and '.join(needs)}",
f"The user installs it: {browser.PREFLIGHT_WITH}")
return Check("browser", "OK", f"not installed - optional, needed only by `raw login` and handlers "
f"that declare `render`; the user installs it with {browser.PREFLIGHT_WITH}")
def check_session_id() -> Check:
"""Three-valued, not two: an explicit `WIKITOOL_SESSION_ID` and a
recognised harness variable (see `chemenu.session.HARNESS_ENV_VARS`) both
keep a session's calls in one telemetry/budget bucket, so both are `OK`.
Only the `getppid()` fallback - a fresh "session" on every call, on a
harness that runs each tool call in its own shell - is a `WARN` (see
Gitea #110)."""
import os
if os.environ.get(SESSION_ENV_VAR, "").strip():
return Check("session-id", "OK", f"{SESSION_ENV_VAR}={os.environ[SESSION_ENV_VAR]}")
source = _session_id_source()
if source != "getppid() fallback":
return Check("session-id", "OK", f"scoped by harness variable {source}")
return Check(
"session-id", "WARN",
f"{SESSION_ENV_VAR} is not set and no harness session variable was found - "
"budget falls back to the parent PID",
"See instructions/session-setup.md",
)
def check_stack_version() -> Check:
"""Which stack this instance runs, and where it came from.
A missing `VERSION` is a WARN, not a FAIL: instances exported before the
stack was versioned are still perfectly functional - they just cannot
answer `version check`. A malformed one is a FAIL, because then something
edited a generated fact by hand and every comparison built on it is wrong.
"""
try:
current = version_mod.read_version()
except version_mod.VersionError as exc:
if not version_mod.version_file().is_file():
return Check(
"stack-version", "WARN", "No VERSION file - this instance predates stack versioning",
"Re-export from a current origin, or write the version this instance corresponds to",
)
return Check("stack-version", "FAIL", str(exc), f"Fix {version_mod.VERSION_FILENAME} by hand - it holds one semantic version, nothing else")
try:
stamp = version_mod.read_stamp()
except version_mod.VersionError as exc:
return Check(
"stack-version", "FAIL", str(exc),
f"Delete {version_mod.RELEASE_STAMP_FILENAME} or restore it from the release it came from",
)
origin = "development tree" if stamp is None else f"distribution, exported {stamp.get('exported_at', 'unknown')}"
candidate = " - a running pre-release candidate, not yet fixed by `version release`" if current.is_prerelease else ""
return Check("stack-version", "OK", f"{current} ({origin}){candidate}")
def check_kb_version() -> Check:
"""Whether the content is in the shape this machinery expects.
A `WARN` when the content lags: that is the normal, transient state in the
middle of an upgrade, not a fault - and `migrate status` names the chain
that closes it. A missing declaration is also a `WARN` (an instance from
before the file existed still works), an unreadable one a `FAIL`.
"""
from chemenu import kb_state
try:
stack = version_mod.read_version()
except version_mod.VersionError:
return Check(
"kb-version", "WARN", "No stack version to compare the content against",
"See the stack-version check above",
)
try:
kb_version = kb_state.read_kb_version()
except version_mod.VersionError as exc:
return Check(
"kb-version", "FAIL", str(exc),
f"Restore or delete {kb_state.KB_STATE_FILENAME}, then "
"`tools/wikitool migrate baseline <version>`",
)
if kb_version is None:
return Check(
"kb-version", "WARN",
f"{kb_state.KB_STATE_FILENAME} is missing - the content's shape is undeclared",
f"Run `tools/wikitool migrate baseline {stack}` if this instance's content has "
"never lagged behind its machinery",
)
if kb_version < stack:
pending = kb_state.chain(kb_state.load_migrations(), kb_version, stack.base)
if pending:
return Check(
"kb-version", "WARN",
f"Content is at {kb_version}, machinery at {stack} - "
f"{len(pending)} migration(s) outstanding",
"Run `tools/wikitool migrate status`",
)
return Check("kb-version", "OK", f"{kb_version} (nothing outstanding up to {stack})")
return Check("kb-version", "OK", f"{kb_version}")
def run_doctor() -> list[Check]:
checks: list[Check] = [
check_python(),
check_tool_paths(),
check_ripgrep(),
*check_import_filters(),
check_install_dir(),
check_execution_policy(),
check_script_marks(),
check_author(),
check_stack_version(),
check_kb_version(),
*check_git_repo(),
check_skills(),
check_structure(),
check_personalization(),
check_conventions(),
check_environment(),
*check_legacy_settings(),
check_publish_remotes(),
check_sync_folder(),
check_backup(),
check_upload_intake(),
check_tasks_provider(),
*check_settings(),
check_site_handlers(),
check_browser(),
check_generated_files(),
check_session_id(),
check_telemetry(),
]
return checks
@cli_contract.record(cli_contract.CommandRecord(
path="doctor",
summary="Check that this instance is correctly configured.",
synopsis=(cli_contract.Variant(usage="doctor [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
network=cli_contract.Network.YES,
),
notes=(
"Checks dependencies (Python, ripgrep), author resolution, stack version, git "
"identity/branch/remote, published skills, the kb/raw/reports/work/sites/instructions "
"structure, and generated files.",
"Import filters (`import-filter-<name>`): one line for each `raw derive` format that "
"reads through a package (PDF, Outlook `.msg`) - a `WARN` naming the package when it is "
"missing, since that costs only its own format; fixed by running `tools/preflight.sh` again.",
"Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that "
"finished, every tool `tools/prerequisites.txt` names for this platform recorded, and "
"every recorded path still there - a `FAIL` otherwise, fixed by running "
"`tools/preflight.sh` again (PowerShell 7: `tools/preflight.ps1`).",
"Install folder (`install-dir`): on Windows with long paths off, a `FAIL` when the "
"folder holding `tools/` is longer than `tools/prerequisites.txt` allows (95 "
"characters) - the limit the preflight enforces before it sets anything up.",
"PowerShell (`execution-policy`, `script-marks`; Windows only, `OK` elsewhere): a `FAIL` "
"when the effective execution policy is `Restricted` or `AllSigned` - the line then says whether "
"a group policy sets it, which only whoever administers the computer can change - and a "
"`FAIL` when a script under `tools/` carries a Mark of the Web from the internet zone, "
"which a browser download unpacked in Explorer leaves behind and `Invoke-WebRequest` plus "
"`tar` do not. `tools/preflight.ps1` checks the same two things before it stops.",
"Personalization: `USER.md`/`SOUL.md` present **and** filled - a file still carrying "
"the template's sentinel is a `FAIL`.",
"KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three "
"tool-owned section headings - a `FAIL` on any of the three.",
"Environment note: `ENVIRONMENT.md` is optional, so absent is `OK`; a still-templated "
"one is a `WARN`.",
"Legacy settings (`legacy-settings`): one `FAIL` for each per-feature settings file of "
"an earlier version (telemetry, remotes, upload, task tracker) that is still there. Nothing reads them any more, so the settings they hold are not in effect; "
"the fix is `tools/wikitool config migrate`.",
"Synced folder (`sync-folder`): a `WARN` when the git directory, links and junctions "
"resolved, lies under a folder named by `OneDrive`, `OneDriveCommercial` or "
"`OneDriveConsumer` (compared without regard to case on Windows) or under "
"`~/Library/CloudStorage/` (macOS); `OK` otherwise, never a `FAIL`.",
"Backup (`backup`): the `backup` section absent is `OK`, malformed is a `FAIL`. "
"Configured, a `WARN` when `<target>/git` is not a bare repository, `origin` does not "
"push there, or - with `machine_data` true - no machine-data backup exists yet; "
"otherwise `OK` with the age of the last machine-data backup.",
"Intake channels (`upload-intake`): the channels of the `intake` section with their "
"handler, and how many submissions wait in `quarantine/`. Absent is `OK` and means no "
"channel - no `submit` tool, nothing to collect; malformed is a `FAIL`, and so is an "
"`mcp-upload/` of an earlier version that still holds submissions (the message names the "
"command that moves it). A `folder` channel whose `path` does not exist is a `WARN`.",
"Task tracker: the `tasks` section present, absent or malformed - absent is `OK` "
"(no tracker configured), malformed is a `FAIL`. When `WIKITOOL_TASKS_CONFIG` is set, "
"the profile it names is read instead of the `tasks`, `superproductivity` and `caldav` "
"sections and the finding names it; a set variable that names no file is a `FAIL`, "
"never `OK`.",
"For a configured `superproductivity` provider, the configured `access` path's own "
"state: `access: \"api\"` reports whether its local REST API answers `GET /health` "
"with a ready renderer right now, `access: \"snapshot\"` whether a backup file is ready. The other access "
"path is never attempted, and neither state is ever a `FAIL`.",
"For a configured `caldav` provider, whether the server is reachable and Basic auth "
"succeeds - never a `FAIL`; only a broken config block is.",
"Settings (`settings-instance`, `settings-checkout`, `settings-secrets`): one line per "
"tier. `wikitool.json` and `.wikitool-local.json` each present or absent - absent is "
"`OK` - and a `FAIL` naming file, key and the right tier when a value sits in the wrong "
"file, a secret or a committed private field holds a plain value, or a value fails its "
"field; a section this version does not read is a `WARN`. For the references: how many "
"resolve, never a value - an unresolvable one is a `FAIL`, one 1Password will not hand "
"out until a human unlocks it a `WARN`.",
"Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, "
"`WARN` only for the bare parent-pid fallback.",
"Telemetry: on or off and why - installation-form default, the `telemetry` section of "
"`.wikitool-local.json`, or `WIKI_TRACE` - and the current session's count and byte total "
"against both caps; never a `FAIL`. A `WARN` when that section is not read as written - "
"unreadable JSON, not an object, `enabled` not a JSON boolean, a cap not a positive "
"integer, or an unknown key - naming the field; telemetry itself drops or reinterprets "
"such a value without a word, so this is where it shows.",
"Site handlers (`site-handlers`): every file in `sites/` is read against the handler "
"schema and the rules the schema cannot state; `FAIL` for a file that cannot be used, "
"naming the file and the reason, and `OK` listing the instance's handlers, each one that "
"replaces a stack handler (the same host and path) named as such.",
"Browser component (`browser`): whether the optional Playwright component and its browser "
"are installed in the venv - `OK` either way, never a `FAIL`; a `WARN` only when it is "
"missing and the instance needs it: a host `fetch.routes` sends through the session "
"(`raw login`), or a site handler that declares `render`. The user installs it with "
"`tools/preflight.sh --with browser`.",
"Exits 1 only on a `FAIL`; a missing remote, session id or `VERSION` is a `WARN`, not a "
"fault.",
"Read-only and exempt from the Iteration Budget Gate.",
),
failures=(cli_contract.Failure(
cause="At least one check reported `FAIL` (a `WARN`, e.g. no remote or no "
"`WIKITOOL_SESSION_ID`, does not exit 1)",
reaction="Each finding names its own fix command; re-run after applying it",
),),
examples=(
"tools/wikitool doctor",
"tools/wikitool doctor --json",
),
see_also=(
"`instructions/setup-instance.md` - the setup steps most findings point back to",
"`instructions/preflight.md` - what `tool-paths` and `install-dir` point back to",
"`docs/reference/configuration.md` - the per-checkout configuration files and the tiered "
"settings",
"`docs/explanation/telemetry-and-evaluation.md` - telemetry state and caps",
f"`{BACKUP_HOW_TO}` - what `sync-folder` and `backup` point to",
"`instructions/session-setup.md` - setting `WIKITOOL_SESSION_ID`",
),
))
def doctor_command(
json_out: bool = typer.Option(False, "--json", help="Print the checks as JSON"),
):
"""Check that this instance is correctly configured.
\f
Dependencies, recorded tool paths, install folder length, author, git identity/remote, published skills, structure,
personalization, KB conventions, generated files, session scoping,
telemetry state, which intake channels are configured, and which
task-tracker provider (if any) is configured for the GTD review.
Read-only. Exits 1 only if a check FAILs."""
checks = run_doctor()
if json_out:
typer.echo(_json.dumps([c.__dict__ for c in checks], indent=2))
else:
for check in checks:
color = {"OK": "green", "WARN": "yellow", "FAIL": "bold red"}[check.status]
line = f"[{color}]{check.status}[/{color}] {check.name}: {check.detail}"
if check.fix and check.status != "OK":
line += f"\n fix: {check.fix}"
typer.echo(line) if False else None
from rich.console import Console
Console().print(line)
if any(check.status == "FAIL" for check in checks):
raise typer.Exit(code=1)