Skip to content

Latest commit

 

History

History
224 lines (155 loc) · 103 KB

File metadata and controls

224 lines (155 loc) · 103 KB

Changelog

All notable changes to this project are documented here. The format follows Keep a Changelog and the project uses Semantic Versioning.

Added

  • build-ollie-site is now installable as a standalone skill in any harness a generic installer reaches — npx skills add Kntnt/kntnt-wp-skills — alongside the plugin channel, which stays the primary one. Such an installer copies the skills/<name>/ directory and nothing else, and everything this skill needs already lived there but one thing: the help gate reached the plugin's manual-page renderer through ${CLAUDE_PLUGIN_ROOT}, an environment variable that exists only inside a Claude Code plugin install. The gate now names ../../scripts/help.py relative to the skill's own directory and checks whether it is there — a plugin install echoes the manual page as before, a standalone install falls back to a brief usage summary drawn from the skill document itself — so the one thing the directory cannot carry degrades instead of breaking. A path preamble states the anchor every other relative path in the document is read against (the directory holding SKILL.md, wherever it was installed), and the README documents both channels. Nothing about the skill's behaviour under the plugin changes.
  • The transfer engine — clone and pull — is installable as a standalone skill pair as well, so npx skills add Kntnt/kntnt-wp-skills now carries all four skills and no skill is hidden from that channel any more. Neither of the two reasons it was Claude Code-only was fundamental. The engine's helper scripts are plain Python that reads JSON on stdin, and they now ship where the skill that drives them can carry them; the four bundled subagents were Claude-specific machinery wrapped around instructions any competent agent can follow, and those instructions are now files. Both skills are consequently capability-aware: they run each heavy phase by the first of three tiers the environment supports — the pinned Claude Code subagent, else a subagent or background task the harness can spawn, else the orchestrator executing the role file itself, inline and in the order the document prescribes. The tier decides who runs a phase and whose context absorbs it, never what the phase does or what evidence it must return. A run without subagents is therefore slower and strictly sequential, not degraded: same steps, same deterministic helpers, same evidence blocks, and the same refusal to trust a result that arrives without one.
  • Every noisy step now suppresses its own noise, in every harness. skills/clone/scripts/wp_quiet.py runs the thumbnail regeneration and the search-index rebuild — owning the reindex probe-then-run pair and its three outcomes (rebuilt, cli-unavailable, not-present) — writing every per-image progress line and cosmetic warning to a log file under the run's scratchpad and printing one compact JSON summary: the exit code, the count, the log's path and line count, and any genuine Error: or fatal line. smoke_test.py gained --log <path>, which writes the full report there and prints only the verdict, the pass/fail/attention/skip counts, and the fail/attention findings; poll_extraction.py gained the same flag for its per-poll progress lines, which on a multi-hour extraction are the single largest source of transport noise in the run. This was the subagents' main job besides parallelism, and it is now a property of the scripts rather than of one harness's context boundaries — which also makes the delegated path quieter than it was.
  • mkwp is installable as a standalone skill too, so npx skills add Kntnt/kntnt-wp-skills now carries it alongside build-ollie-site. It was only ever marked Claude-only for scoping reasons: the sole things it reached outside its own directory were two plain Python helpers, mkwp_guard.py (the version floor its dependency check enforces) and classify.py (the directory-name derivation it shares with clone), and both now ship inside the skill. Its help gate degrades exactly as build-ollie-site's does, it carries the same preamble fixing the anchor its relative paths are read against, and the companion documents it cites — the specification, the glossary, the ADRs behind its gate shape — are linked by URL rather than by a repo path a copied directory would not have. This settles the rule the rest of the restructuring follows: a portable skill may never depend on a hidden one, but a hidden skill may depend on a portable sibling. The plugin channel always installs every skill, so clone, pull, and the bundled subagents can reach into mkwp safely; the reverse would break the very channel the internal marker exists to protect, because a generic installer never carries a hidden skill.
  • The always-excluded pattern family gained every credential-bearing shape beside wp-config.php it was still missing, and the rest of OpenSSH's default key basenames. skills/clone/scripts/build_exclusions.py now also excludes Emacs' auto-save file (#wp-config.php#) and lock file (.#wp-config.php) — the first holds the live file's complete secret family verbatim — and the reordered backup names a manual edit leaves behind (wp-config.bak, .old, .orig, .save, each with or without a trailing .php), which put the marker ahead of the extension where the existing wp-config.php.* catcher never saw them. The root key-material family gained id_dsa*, id_ecdsa*, and id_ed25519* beside id_rsa*, covering the -sk hardware-token variants through the same prefix and, deliberately, the .pub sibling of each: a public key at the install root is not a secret, but it is a strong signal the private one is lying beside it, and neither is site content. The names are enumerated rather than caught with a bare wp-config.*, which would reach further than the shape being closed, and wp-config-sample.php — WordPress' own placeholder-only template — stays transferable, pinned by its own test alongside the false-positive controls that keep a theme's config.old and an uploaded id_ed25519-tutorial.png in the copy.
  • The always-excluded pattern family now covers the dot-prefixed configuration file whole, not only vim's swap files. skills/clone/scripts/build_exclusions.py carried .wp-config.php.sw?sw plus one character, and nothing else — so .wp-config.php itself and every non-swap suffix beside it (.bak, .save, .orig, a bare .1) passed the pre-filter and entered the selection, each holding the live file's complete secret family in clear text. The retired entry is replaced by .wp-config.php and .wp-config.php.*, which subsume it. The Kntnt Extractor's restricted-path policy is widening to /^\.wp-config\.php(\..+)?$/i, and it refuses the whole POST /extractions when a selection names a path it covers, so each of those five shapes would have cost a live run a wasted round trip; landing this client's pre-filter ahead of the server is the harmless direction of that drift, and the refusal handling at submission is what keeps it survivable in either direction (ADR-0024). Two entries rather than one broad .wp-config.php* glob, so the shape is exactly the server's regex and no wider — the same enumerate-rather-than-broaden reasoning that rejected a bare wp-config.*. This list stays a pre-filter on what the client asks for and is still never asserted equal to the server's (#67).
  • The always-excluded pattern family now matches a copy of the configuration file anywhere in the tree, not only at the install root. skills/clone/scripts/build_exclusions.py anchored all sixteen wp-config entries at the root by #36's reading — a same-named file nested deeper in the tree is ordinary content — while the Kntnt Extractor has matched the whole family against basename() at any depth since 0.3.0 and refuses the whole POST /extractions when a selection names one. A site carrying a copy under wp-content/ — a backup plugin's staging copy, a duplicated install under a subdirectory, a developer's snapshot — therefore cost a live run a wasted round trip on every clone whose scope reached it. The fifteen entries whose basename can only ever name a configuration copy (the plain name, the appended and tilde backups, the dot-prefixed config with any suffix or none, Emacs' auto-save and lock files, and the reordered wp-config.bak / .old / .orig / .save names with or without a trailing .php) now carry the **/ marker the .env family has used since #36, so each is matched by basename at any depth. The one broad wp-config-*.php variant catcher deliberately stays root-anchored: that shape can legitimately name a file which is not a configuration copy at all, and it is the only entry the wp-config-sample.php carve-out has to defend against — leaving it alone is what keeps that carve-out correct as an exact whole-path comparison and keeps WordPress' own bundled template transferable at the root and nested under a theme or plugin, exactly as the Extractor, whose exception is a negative lookahead on the basename, accepts it. Widening the catcher too would have dropped that template wherever a theme ships a copy, making this client stricter than the server on the one file the server explicitly protects. The anchoring is expressed in the pattern itself rather than in a new matcher branch, so both matcher copies inherit it from the one constant. A nested wp-config-local.php is still sent and still refused, at the round trip ADR-0024 bounds: this list stays a pre-filter on what the client asks for, and nothing asserts it equals the server's. See ADR-0031 (#75).
  • Strict static type checking is now a verification step the project actually runs, beside the test suite: one command, uv run --with mypy … mypy (CONTRIBUTING.md gives it verbatim), with strict mode and the checked directories declared in a new root pyproject.toml so the command carries no flags of its own and reproduces the enforced result from the repository's own configuration. The Python module of the coding standard had required "strict mode on new code" since it was written and named ruff as the project's linter, while the repository carried no configuration for either and no CI — so neither could ever run. Two strict-mode regressions landed unnoticed as a result, three separate verifiers ran a checker of their own accord and correctly declined to fail a ticket on a check the project does not run, and thirty-three ruff mentions accumulated across plans/ because a bare run reports findings nobody configured and nobody owns. A type error now stops work from landing; a lint finding does not — yet. The check covers the deterministic helper seam and only it (scripts/, skills/clone/scripts/, skills/mkwp/scripts/ — the same three homes pytest.ini puts on the import path), and skills/build-ollie-site/scripts/ is outside it deliberately, stated in the configuration rather than left as an omission a reader has to notice: that skill shares none of the transfer engine's machinery and its helpers are written against a different baseline. The one declared third-party dependency is provisioned rather than ignored — with nacl.bindings invisible, mypy invents a no-any-return in the unseal helper that does not exist, so --ignore-missing-imports would manufacture a finding while blinding the check at the only boundary a real one could hide behind; the pin belongs to skills/clone/scripts/unseal.py's own PEP 723 header, which mypy cannot read, and tests/test_type_check_consistency.py holds every surface quoting the command to it, so no further copy can drift. The seam is clean at 17 files and zero errors: the five remaining findings were repaired at the type level only, skills/clone/scripts/poll_extraction.py narrowing the job-listing response before reading it — the else: arm of a try is where mypy cannot see that the failure path assigned None, so it becomes an explicit is not None guard entered under exactly the same condition — and skills/clone/scripts/resolve_plan.py making a comparison over dict[str, Any] concrete with bool(...), a no-op at runtime. No helper's behaviour changes, every helper keeps its original requires-python floor and dependencies list, and nothing is silenced with a type: ignore. Ruff stays the chosen linter and is now described honestly wherever it is named — unconfigured and advisory until a ruleset is pinned, so its output is advice and never a verdict on a change; a bare ruff check reports 1 or 90 findings depending on the ruleset, which is why the choice is left to the maintainer. CONTRIBUTING.md, agents.d/coding-standard/python.md and plans/README.md all name the same command, and docs/spec.md states the same division while pointing at CONTRIBUTING.md for the command itself. See ADR-0032 (#74).
  • The main extraction now carries the file-part budget it is packaged at, so that number is the client's rather than whatever an invisible host-side pin happens to be. Extractor 0.7.0 accepts an optional chunk_size on POST /extractions, and grep -rn chunk_size over this repository returned nothing at all: every extraction was packaged at whatever the host resolved for itself through a constant-then-filter config seam that no endpoint reports and that reporting was deliberately rejected for upstream (Kntnt/kntnt-extractor#32). That value is not a detail — lowering it to 256 KB was the single deliberate difference between a production clone that died at 97.8 % after six hours and one that finished in 3.56 h, and on the site where it mattered it lived as a filter inside a Perfmatters code-snippet collection materialised as an mu-plugin, a place no file search of the site's plugins reaches. skills/clone/scripts/resolve_plan.py gains a chunk_size decision with a built-in default of 262144 (256 KB, the one value measured to complete a real clone) over an optional saved-plan chunk_size key, and an ordinary gate in the ordered walk like every other decision (ADR-0005) — no new seam, and no new flag, since --yes already walks no gates and takes the resolved layered value (ADR-0013). A value that is not an integer of at least 1 is refused locally, naming it, so production is never asked to answer a 422 kntnt_extractor_malformed_body hours into a run. Only the main extraction carries the member: the preflight and the bootstrap submit tables and no files, so neither packages a file part, and their payloads are unchanged. The member is sent whether or not the health check's honours list names it — the same discipline the state=all sweep follows, since the member is additive and an Extractor that does not know it ignores it; skills/clone/scripts/discovery.py carries that list through the run as an optional sibling of api_version, read from the authenticated GET /status (the anonymous handshake reports the version alone), and the run's report uses it only to state afterwards that the member was ignored and that the remedy is upgrading production's Extractor. The report names the budget the run asked for and the layer it came from either way, so the number is recoverable from the record. A site deliberately tuned below 256 KB must record that number in its saved plan, or a run overrides it — moving per-site tuning out of an mu-plugin and into .kntnt-wp-skills.json, where it is visible and committed, is the point rather than a side effect (#77).
  • What a change owes the documentation is now written down in this repository, and so is where the obligation stops. The documentation round tickets cite as R3 — a change of substance touches CHANGELOG.md, CONTEXT.md, an ADR, and on the skills side docs/spec.md and every SKILL.md it touches — was defined in a file on one machine that no clone carries: four tickets in the unattended run of 2026-08-21 charged work against it by name, every readiness check flagged that the citation resolved to nothing in-repo, and every builder resolved it from precedent and passed. Its boundary was undocumented in the same way and cost more: #76 declared in its own scope that this repository's test-only changes carry no changelog entry, the builder wrote none and the verifier passed it on that basis, and the integrated-branch coherence check — deliberately given no ticket text, so that its reading of the branch stays independent — read the gap as a defect and named it twice. It was not wrong; it was reading the rule's absence, and would have regenerated the same finding on every future wave that merged a test-only ticket. Both rules now live in AGENTS.md, which is always loaded and authoritative, rather than in CONTRIBUTING.md (a page a human reads once at setup) or docs/spec.md (whose subject is what gets built, not how the work is conducted). The boundary is the files touched, not the size of the effect: a change confined to tests/ carries no entry, and a change touching a helper, a skill document, an operator-facing document or the tool configuration carries one however small. See ADR-0035.
  • The type-check guard now holds every surface that quotes uv run --with mypy --with pynacl==1.5.0 mypy, not only the three it held before. pyproject.toml's own comment and the code fence in ADR-0032 quote the command too and sat outside tests/test_type_check_consistency.py's list, so changing skills/clone/scripts/unseal.py's pynacl pin would have reddened three surfaces while those two went quietly stale — the decision record misleading exactly the reader who opened it to find out why the pin exists. Both are added, and both documents now name the full set rather than three. The record is held alongside the instructions but kept off the prescriptive list the missing-imports check sweeps, because ADR-0032 names --ignore-missing-imports in order to refuse it, which is precisely what that check forbids an instruction from doing.
  • The mkwp floor-version suite no longer mistakes a package pin for a stale mkwp floor. tests/test_mkwp_floor_version_consistency.py bans the pre-fix mkwp floor's version string from seven documents, and since #74 the pynacl pin in the type-check command is a legitimate version in this repository that happens to be exactly that number — so any future prose naming pynacl's pin in one of those seven files would have reddened that suite for a version with nothing to do with mkwp. #74 worked around it by describing the pin instead of quoting it. Every name==version pin is now discounted before the scan, so only versions stated in their own right are judged, and a test pins both directions of that distinction.

Changed

  • scripts/classify.py and scripts/mkwp_guard.py moved to skills/mkwp/scripts/, the skill that has to carry them. Neither script changed a line — both were already self-contained, reading JSON on stdin and writing JSON on stdout — but every consumer now names the new home, and the test suite imports and runs them from there. With the engine's own move that followed, the deterministic helper surface is one surface with one home per owner, which docs/spec.md now states outright rather than leaving a reader to discover.
  • The transfer engine's twelve helper scripts, the extraction-poll helper, and the local capture mu-plugin template moved from the plugin root into the skill that drives them: skills/clone/scripts/ and skills/clone/templates/. Not a line of any script changed — they were already self-contained — but the paths every surface names did: clone names its own (scripts/unseal.py), pull names its sibling's (../clone/scripts/unseal.py), both reach the classifier at ../mkwp/scripts/classify.py, and the plugin-only surfaces (the subagent definitions, the manual pages) name ${CLAUDE_PLUGIN_ROOT}/skills/clone/scripts/…. grep -rn 'CLAUDE_PLUGIN_ROOT' skills/ now returns nothing: the plugin root holds only scripts/help.py, the manual-page renderer no skill directory could carry, which every skill's help gate reaches through the same checked, degrading fallback build-ollie-site established. Each skill's health check verifies its sibling dependencies before touching anything, so a partial standalone install fails cheaply with a remediation rather than deep inside discovery.
  • The four subagents' procedures are single-sourced as role files under skills/clone/roles/, written to be executed by any competent agent: inputs, steps, the exact evidence block to return, and the hard rules. The definitions under agents/ remain, because Claude Code loads its subagents from there and nowhere else, but each is now its pinned frontmatter plus a pointer to its role file — no procedural text is duplicated between the two locations, and the audit suite fails a wrapper that grows any. The consistency suites that used to read the agent bodies read the roles instead, and docs/poll-discipline.md names the role files as the surfaces that must carry the discipline verbatim.
  • The smoke test's sample URLs come from WordPress now, not from whoever calls it. sampleUrls was the one expectation input a caller could plausibly build wrongly, and a wrong one failed in the most expensive shape a false positive has: the report pointed at the copy, which was fine, instead of at the input, which was not — one such URL was among the two findings that returned a spurious FAILED on the 2026-08-19 production clone. skills/clone/scripts/smoke_test.py now asks the copy under test for its own front page, a published post, a page that is not the front page, and a non-empty category archive, over four documented WP-CLI listings; a site that genuinely has none of a kind contributes none, and a site that cannot be asked at all earns attention rather than a fail, because an input that could not be gathered is a bad input and not a bad copy. A caller may still override the list — a multilingual install's localised home and subpage, the rewrite-flush canary, is a URL the copy cannot derive — but the override is recorded: the report, its compact --log summary, and the thumbnail-smoke-test role's evidence block all carry derived/supplied/none, since an unmarked override reproduces the same defect one step further from the reader. Where the source site's own permalink structure and front-page pair are recorded, they are compared against the copy's and a disagreement is reported with both values named — an anomaly, never a failure, since a source that has moved on since discovery took its snapshot is not a broken copy (#60).

Fixed

  • The client decided whether the Extractor had withheld a define's value by looking at the value, which the Extractor's own docs/define-disclosure.md says a reader MUST NOT do — disclosure is the only reliable signal, and it has been on every defines record since Extractor API version 7, which production now runs. Two things followed. A define on the server's allow-list whose real value is null was reported to the operator as one "the Extractor did not disclose", a false statement about a live production site; and every withheld define got the same one-line remedy no matter why it was withheld, even though secret (a name shaped like a credential, which generally should stay withheld) and not_allow_listed (a name the site operator can opt in on production through the Extractor's KNTNT_EXTRACTOR_DISCLOSABLE_DEFINES constant) call for opposite advice. skills/clone/scripts/discovery.py now carries the discriminator into the canonical document — omitting the key entirely when the server sent none, because that absence is what distinguishes a pre-protocol Extractor — and skills/mkwp/scripts/classify.py asks the name-based classes first, then the discriminator, and only then falls back to the value. The enum is closed, so a verdict this client does not recognise is treated as a withholding even when the record carries a present value; reading an unknown verdict optimistically would port a value the server had declined to disclose. Each withheld record now carries a reason, and both run reports branch on it into four distinguishable remedies where they gave one. No define becomes portable that was not portable before: a disclosed null is still never written, because define('NAME', null); makes defined('NAME') report true and suppresses the owning plugin's not-configured fallback whether the null was the server's mask or production's own value — what changed is only that the operator is now told which of the two it was. The value-based rule of ADR-0020 survives as the fallback for an Extractor predating the protocol, which this client still supports because its API-version floor is 2. See ADR-0023, which refines rather than supersedes ADR-0020.
  • A subagent could carry the main extraction's multi-hour poll off with it when its process tree was reaped. extract-transfer owned the whole phase — submit, poll, download, unseal, consume — and was instructed to wait inside one blocking poll_extraction.py invocation and return once with a verdict; on both production runs it returned without one, and the second time it had detached the poll first, so the poll died with the agent: a zero-byte output file, no exit code ever written, and a job that ran on to 13,459 files with nobody watching. Since the close-out for a verdict-less return is DELETE, only an unwritten manual state check stopped a healthy job being cancelled. The phase is now split at the poll boundary: the new extract-submit role submits and writes the accepted job to <scratchpad>/extract-job.json before anything waits on it, both skills poll it themselves as their own tracked background job — never delegated, never detached — with the verdict captured to extract-poll.json and the exit status to extract-poll.exit beside it, and extract-transfer is entered on an already-ready job to download, unseal and consume. A lost poller is now a re-poll rather than a lost run, because the id is on disk and polling is read-only. scripts/poll_extraction.py is unchanged; what moved is who invokes it (ADR-0027).
  • A subagent that returned nothing could get a healthy extraction cancelled. extract-transfer's verdict is read as FAILED when the result arrives without its evidence block, and the close-out mapped a FAILED whose stall window was exhausted to DELETE /extractions/{id} — so silence, the least informative return possible, was routed onto the most specific and the only destructive case. This stood between a live extraction and a DELETE on both production runs; on 2026-08-19 the job it would have cancelled was two and a half hours in and measurably advancing (state: running, chunks_done climbing) and went on to complete all 48,578 files, and the only thing that stopped the cancel was the orchestrator querying the job by hand, contrary to the written close-out. Both SKILLs now re-query GET /extractions/{id} once before choosing a close-out case at all, on any FAILED verdict and equally on any absent or malformed one; the job's own reported state outranks the subagent's claim about it, a job reporting advancing progress selects no case and the run resumes polling it, and an absent verdict is stated outright not to be evidence of an exhausted stall window. The extract-transfer role scopes its verdict to match — FAILED binds the role's work, never the job's state. Nothing about what the close-out cases do changes: the re-query gates the cancelling branch only and never postpones case 2's consume after a complete download. See ADR-0022.
  • A consume the Extractor refuses because the job's lock is held no longer fails a finished run. Both purging routes — POST /extractions/{id}/consume and DELETE /extractions/{id} — now take the per-job tick lock on the server, because either could otherwise delete the directory a live build was still writing into, and answer 409 kntnt_extractor_locked when they cannot take it; the Extractor's intended handling is that the caller retries. This client had no handling for that code at all, and its own rule that a job never consumed on the happy path is always FAILED turned a narrow lock contention into a failed verdict on a completed multi-hour extraction. The consume is now retried up to five times, 10 seconds apart, and only an exhausted window fails — a bound read off the Extractor's own lock discipline rather than picked, since the longest any actor holds a job's lock is one tick_budget (15 s by default) plus the single chunk that may overrun it, and the TTL sweep holds it only across one job's purge. The data was never at risk on this path: the download and the unseal both precede the consume, so what an exhausted window forfeits is the run's verdict and the immediate close of the exposure window, never the copy. The exhausted case is consequently reported as its own unsealed_consume_locked failure phase and closed out as a complete transfer whose artifact awaits its TTL, not as a failed one. The same 409 on the cancel path used by the health check's stranded-job sweep is deliberately left alone — a failed cancel there is already a reportable condition rather than a run-ending one. See ADR-0022.
  • A restricted-path refusal at submission is handled instead of ending the run on an error code this client had never heard of. The Extractor rejects the whole POST /extractions when a selection names a path its own restricted-path policy covers — 422 kntnt_extractor_restricted_path, every offender in data.paths — and grep -rn "kntnt_extractor_restricted_path" over this repository returned nothing at all: there was no handling anywhere, and the submission step described 422 only as "malformed or overlapping selection" and hard-stopped on it. The practical effect was an unhandled error at the exact step meant to begin a multi-hour transfer, on a site whose only fault was carrying a wp-config.old or an id_ed25519. The extract-transfer role and both SKILLs now report every path the server named, drop exactly those from the selection's files, and resubmit the create once — the refused request created no job, so nothing starts twice — with a second identical refusal a hard stop, and the dropped paths carried in the evidence block as restricted_paths and named in the run report beside skipped_files. The widened patterns above only make the refusal rarer; this is what makes it survivable, and it has to be, because the client's list mirrors policy that lives in another repository and is allowed to move without an api_version bump. See ADR-0024.
  • The unseal step counted the files it wrote rather than the files that landed, so a copy could report a complete transfer while quietly holding fewer files than it received. On the 2026-08-19 production clone it reported files_written: 48578 against 48,552 distinct files on disk: twenty-six pairs of paths that differ only by Unicode normalisation — å as one code point on one and as a plus a combining ring on the other, two files on Linux and one on APFS — of which eight were byte-identical and harmless and eighteen had different sizes, so one variant of each was silently overwritten by the other. Nothing in the run said a word about it. skills/clone/scripts/unseal.py now groups the file list on its NFC form before writing and reports every group larger than one as normalisation_collisions, naming each spelling, and counts the distinct files that actually landed by their own (device, inode) identity as files_landed beside files_written — a count measured on the disk rather than derived from what was attempted, which is the only kind that can notice a merge. Both SKILLs name the groups in the run report, with what macOS did to them and which bytes survived. This does not prevent the collision, repair the 2026-08-19 copy, or decide what to do about a merged file: it is a property of the destination filesystem, a rename would break WordPress's own references to the file, and a merged .webp derivative that the thumbnail regeneration rebuilds is a very different matter from a merged original — which is a judgement left to the operator, who now at least learns of it. A collision is never fatal and never fails a run. The sibling case — two Linux files differing only in case, which a default macOS volume merges in exactly the same way — is not detected here and is tracked as issue #61. See ADR-0025.
  • An api_version of true passed the type check written to reject it. bool subclasses int in Python, so isinstance(True, int) is true and every integer field in skills/clone/scripts/discovery.py took a boolean for a number — the required fields, the optional ones, the entity counts' own check, and the file manifest's tolerant size read. A true api_version would have ridden into the canonical document and been compared numerically downstream as a 1, and a table's bytes of true would have been summed into the grand total as one byte. The exposure was never large — these values come from an Extractor this client authenticates against with the site owner's own credential — and the convention was the module's from the start rather than a regression; what made it worth closing is that the convention is shared, so a single predicate tightens every integer field at once without one call site changing. _has_type is now the one place the module decides what a type check means: a field declared int refuses both booleans, every other declared type keeps plain isinstance semantics, and the refusal keeps the shape and the got bool diagnostic every other type mismatch produces. A field declared bool is deliberately unaffected. The question had been reached once before in this repository and answered locally — wpconfig_block.py already orders its bool check ahead of its int check for exactly this reason — which is why it is settled here rather than noted again. See ADR-0026.
  • The two boundary helpers #64 deliberately left alone took a boolean for an integer through the identical construct it replaced. A manifest entry's size and a subdirectory's size_bytes both passed a bare isinstance(value, int), which bool subclasses its way through, so skills/clone/scripts/baseline_diff.py and skills/mkwp/scripts/classify.py accepted true where every other type mismatch would have failed loudly. One of them was live rather than latent: the baseline-diff helper's mtime reader checks (int, float) and returns float(True) == 1.0, and mtime is half the size+mtime quick-check — a boolean mtime read as one second past the epoch could mark an unchanged file for transfer, or leave a changed one behind. Each module now carries its own _has_type predicate, identical in intent to the assembler's, and its boundary helpers route through it; the mtime reader cannot, since a predicate keyed on expected is int cannot speak for a tuple of accepted types, so it refuses a boolean explicitly ahead of the numeric check — the ordering wpconfig_block.py has documented for this hazard all along. The duplication is deliberate: these are self-contained PEP 723 scripts with empty dependency lists, and a shared predicate module would have the portable mkwp classifier importing from clone, inverting the dependency direction #50/#51/#52 pinned. The rule is stated once in the ADR instead. A field declared bool is deliberately unaffected in both modules — the narrowing runs one way only — and no well-formed document changes shape; what changes is which malformed ones are refused. The poll helper's progress counters are left reading a boolean as 1 on purpose: they are progress arithmetic, not a boundary refusal, and refusing there would turn a wrong number on a progress line into a failed multi-hour poll. That was the question's third asking in this repository, so it is now settled for every boundary helper in the transfer engine rather than for one module — see ADR-0026, amended in place (#70).
  • CONTEXT.md's three ADR citations have never resolved. The glossary reached them as ./adr/… — the form every file under docs/ uses correctly, because those files live in docs/ — while CONTEXT.md itself sits at the repository root, where no adr/ directory exists or ever has. All three targets were real; only the one directory level was wrong. This mattered more than three dead links usually would, because AGENTS.md names CONTEXT.md authoritative: agents are pointed at it by instruction, and these citations are a reader's route from a term to the decision behind it. It had also begun shaping what gets written — a session finishing #58 wanted to cite an ADR from a new glossary entry, found that matching the file's own style would propagate a broken link while using the correct path would make its entry inconsistent, and declined to add the citation at all. The two lines now name docs/adr/…; the ./adr/… form under docs/ is correct as written and is untouched (#73).
  • The last two repo-relative escapes under skills/ now use the canonical GitHub URL, so both resolve for a standalone reader. build-ollie-site's design rationale cited ADR-0002 and clone's WP-CLI quiet runner cited ADR-0015 with a ../../-style path to docs/adr/, which resolves inside a clone and breaks in a standalone install, where the installer copies the skill directory and nothing above it. The first is the one a reader is most likely to hit, because SKILL.md sends readers to that document by name; the second renders nowhere, but it is the same construction with the same failure. Neither was drift — #50's builder and its verifier both saw the first and both correctly left it alone, because the convention that resolves it did not exist yet. Single-level sibling references are a different thing and stay as they are: a portable skill legitimately reaches its installed sibling with ../<skill>/…, and the standalone suite asserts those resolve (#69).
  • An absent verdict against a job the re-query answers ready selected no close-out case at all. Cases 2, 3 and 4 are all ready jobs, told apart only by the failure_phase the role reported — and an absent verdict carries none, which is what makes it absent. Nor is that one route: failure_phase is emitted by exactly one of the three roles that can enter the close-out, so a well-formed FAILED from discovery-classify or from extract-submit reached the same undefined state, on a job that may hold hours of extraction. Nothing was at risk, because #53's re-query is what stops silence being read as case 1 and cancelling a live job; what was wrong is that a destructive path had a reachable state with no instruction for it. The close-out now derives its case rather than reading it off a claim: from the job's own state (the same single GET /extractions/{id}) and from this machine's own state (what the run's scratchpad holds), with a reported failure_phase demoted to a hint that corroborates the report and loses to the derivation where the two disagree — the precedence the spec already states for a remote authority over a local claim, carried one step further to the machine's own state over a session's claim about it. Adding the field to the other two roles was the alternative and was rejected: it can be absent for reasons the close-out can never detect. The probe needed names, so the artifacts got fixed ones: the container downloads to <scratchpad>/extract.container.part and is renamed to <scratchpad>/extract.container only after a clean transfer, which is what makes the final name's existence a trustworthy answer to was a sealed container downloaded?curl -C - leaves a partial file behind, and a truncated container answering for a whole one would select the consume that destroys the only complete copy — with <scratchpad>/extract.sql and <scratchpad>/extract-files/ fixed for the same reason, and the bootstrap job's equivalents kept inside discovery-classify's own per-run working directory, which both SKILLs' §3 had misplaced at the scratchpad root. Cases 2 and 4 became one row: a ready job that was downloaded and unsealed presents identically on disk whether the consume was never attempted or was attempted and refused six times, so the close-out stops trying to detect the difference and attempts the consume — success is case 2, refusal through the whole bounded window is case 4. The risk is asymmetric: an unnecessary consume costs the retry window and nothing else, while a needed one not attempted leaves the sealed artifact on a live client site until its TTL. Both SKILLs now carry the whole mapping as a table, so completeness is visible rather than inferred, including the three outcomes that are not a case — resume polling an advancing job, nothing to close on an already-consumed one, and nothing acted on when the re-query itself could not be answered. Nothing about what any case does changed; only how one is chosen. See ADR-0022 (#65).
  • The thumbnail-smoke-test phase returned FAILED when a command exited non-zero rather than when the copy was wrong, and fired twice on findings that were not failures during a single production clone. The bar was structurally low, not carelessly low: two of the phase's three steps rebuild local artifacts that were deliberately never transferred — the thumbnails (ADR-0011) and the search index (ADR-0015) — so nothing either reports can be evidence about what production sent, while the third, skills/clone/scripts/smoke_test.py, answered 1 for two opposite conditions: its report carries a fail, and it could not read its own expectations file. A verdict read off "non-zero" could not separate those, because the exit code did not. The cost was not cosmetic: the close-out for a failed phase is destructive, and an operator who learns to discount a verdict stops reading it. smoke_test.py now answers with three exit codes — 0 clean, 1 the checks ran and the report holds at least one fail, 2 it could not run at all (a missing clone directory, an unreadable or non-object expectations file, a malformed invocation, or a probe that raised) — and the role's verdict follows mechanically: status is FAILED iff the script exits 1. The role file carries the classification as a table, every non-zero exit its three steps can provoke placed in one bucket with a stated reason, and its last row is the catch-all that closes the original defect: an exit the table cannot place is an anomaly, never a failure. No third verdict was introduced — there are still exactly two, DONE and FAILED, as extract-transfer has them — and an anomaly rides in the evidence block's existing anomalies list beside a DONE, the same channel that already carries skipped_files, restricted_paths, and the normalisation collisions. The report does not get quieter: a step-level anomaly is named with the same specificity a failure would have had, because the point is not a calmer agent but one that stops saying the copy is wrong when it means a command returned 1. Making 1 mean one thing also closed a second route to it: the report write and summary emit that follow the checks sat outside _main_verify's try, so a full disk or an unwritable --log path exited 1 on a clone every check had passed — the code that now fires the destructive close-out. Both are inside the guard and answer 2 instead, because a report nobody could write says nothing about the copy. See ADR-0028.
  • A cancel the Extractor refuses because the job's lock is held no longer ends a run on a cleanup error. #54 taught the consume to retry a 409 kntnt_extractor_locked and deliberately left the cancel out, naming it a follow-up rather than an oversight; this is that follow-up. DELETE /extractions/{id} takes the same per-job tick lock and answers the same refusal, with the same intent recorded in the plugin's own source — the caller simply retries — and the close-out's case 1 was issuing it with no handling at all. The plugin notes that the cancel is in fact the caller most likely to race a live tick, because it reaches a job in any state the run owns, queued and running included, while the consume only ever reaches a job already gone ready. Case 1's cancel is now retried up to five times, 10 seconds apart — the consume's own schedule, off the consume's own derivation: one tick_budget (15 s by default) plus the single chunk that may overrun it, so six attempts across roughly 50 s cover more than three default budgets. One schedule stated twice is a schedule; two would be a coin toss. The cover those numbers buy is weaker here and every surface says so rather than borrowing the consume's confidence: a ready job races a tick only between that tick publishing the artifact and releasing its own lock, while a queued or running job's ticks keep retaking it, so six attempts bound what is attempted rather than guarantee the cancel — accepted rather than answered with a longer window, since an unbounded retry against a live client site is exactly what the bound refuses. An exhausted window is reported, never run-ending: the report names the job id, says the job is still standing on production, and says the plugin's TTL — and the next run's stranded-job sweep where the job is still non-terminal, since case 1 is also entered on a terminal failed job the sweep never lists — will reclaim it, while the run stops on its original cause. The close-out stays best-effort and a cleanup failure never becomes the headline. What an exhausted window costs is bounded but is not merely bookkeeping: case 1 is entered on a job that has not reached ready, so a refused cancel leaves that job's tick running and the job may go on to complete and publish a sealed artifact on the live client site until its TTL — the exposure window ADR-0022 exists to close, reached through the door its locked-consume amendment left open. 429 is not retried here, exactly as it is not at the consume, and every other non-2xx keeps the handling it had. §1.3's stranded-job sweep is deliberately still unretried — a failed cancel there is already reportable rather than run-ending, and changing it would change sweep semantics rather than close-out semantics. See ADR-0022, amended in place (#68).
  • Two of the transfer engine's helpers had lost their mypy --strict cleanliness to work that is correct at runtime, and both are now clean again without moving a Python floor or adding a dependency. skills/clone/scripts/discovery.py's tolerant file-manifest size read had gone through the boundary predicate #64 introduced, and a function returning bool narrows a type for no checker the way the isinstance it replaced did — so a size the predicate had just vouched for reached the child tuple as Any where an int is declared. It is now read through a thin _boundary_int accessor that consults the unchanged predicate and casts what the predicate has already decided, which keeps what counts as an integer in the one place ADR-0026 put it; that ADR also records why a TypeIs annotation is not the repair here. skills/clone/scripts/smoke_test.py's sample-URL derivation bound two different things to one name — a home-URL failure reason, and a per-shape sampling reason that may legitimately be absent — and the second now has its own. Nothing observable moves: the same child tuples for the same manifest, and the same coverage reasons for the same site (#71).

0.10.0 – 2026-08-16

Added

  • The canonical discovery document now carries the Extractor API version the health check observed, as a required top-level api_version sibling of environment — plumbing that previously stopped at the health check itself, discarded the moment the comparison against the floor and ceiling was made. agents/discovery-classify.md passes the number through verbatim rather than re-fetching it, and both skills' run report now states the version production reported and, when it is below this client's ceiling, names each behaviour that is consequently degraded on this host: the identity report's absence below API version 4, the absent progress.chunks_done and widened stall window below API version 6, and strict being silently ignored by any Extractor that predates it. The report was previously silent on all three even though production runs API version 5 today. This plumbing changes no decision — the version pin's floor and ceiling are unchanged — it only makes an existing fact reportable.

Changed

  • Two-phase discovery's "the bootstrap is small" claim is now written down as a premise that holds only where wp_postmeta is small, so a bloated table is a known case rather than a surprise. A skip-wp_postmeta fallback was considered and is not implemented: 493 MB of table versus 279 MB of thumbnails compared InnoDB allocated size to files, while the whole bootstrap container measured 30 MB — a loss on transferred bytes, with only server-side cost remaining (ADR-0017).
  • Both skills now state that a fast preflight is not a promise about the main extraction. The two-table probe proves the loopback and continuation path; a pass in a few seconds says nothing about how a large table or a large file will behave.
  • The poll loop is now scripts/poll_extraction.py: one blocking invocation, one terminal verdict, the seven discipline literals in code so an agent cannot re-derive the cadence, timeouts, backoff, confirmed-vanished check, stall window, or the preflight and bootstrap budgets from prose. The Application Password is KNTNT_EXTRACTOR_APP_PASSWORD in that one process's environment — never argv, never printed. The poll-owning agents and both skills invoke the helper; the consistency suites bind the pinned phrases to the script's constants.
  • The main extraction has no overall wall-clock budget. A 3600 s cap expired on a healthy, visibly-advancing many-file job at less than a quarter of the file phase; sealing cost is per file, not per byte, so a constant cannot be right. The helper now omits the budget argv on that loop and stops only on the stall window — the check that distinguishes dead from slow. Preflight and bootstrap keep their 10- and 15-minute budgets.
  • The main extraction is submitted with strict: false, so a file that vanished between the GET /files walk and the POST is skipped and reported rather than failing the whole job. extract-transfer sends the member, surfaces any skipped_files the create or poll returns, and unseals against the remaining file list — the container only holds what the plugin packaged. A missing table is still a hard 404; the error data now names every missing table and every missing file, so the recovery is no longer a full re-walk. strict defaults to today's hard fail on an older Extractor that ignores the member, and the new fields are additive, so the verified API-version ceiling stays ≤ 6.
  • The verified Extractor API-version ceiling is raised from 6 to 7. The Extractor's GET /environment define-disclosure allow-list is the change that moved api_version, and source inspection of the Extractor's own ADRs, its normative docs/container-format.md, and its artifact-writing commit history since v0.5.1 found no change to the sealed container's byte layout, segment framing, segments-per-resource, sealed index, or reassembly order — the bump is a deliberate compatibility interlock (Extractor ADR-0018), not a shape claim, and scripts/unseal.py needed no change. See ADR-0021.

Fixed

  • A define whose value the Extractor withheld (null on the wire from GET /environment) is no longer ported. scripts/classify.py now classifies such a define auto-excluded under a new withheld class instead of offering it at the wp_config_defines gate, so scripts/wpconfig_block.py never writes define('NAME', null); into the local wp-config.php — a define php -l accepts and the smoke test never catches, but which makes defined('NAME') report true and suppresses whatever fallback the plugin runs for "not configured". This was latent until now: every name the Extractor currently masks is also routed to an auto-excluded class by name, but the Extractor is replacing its secret deny-list with an allow-list, after which a masked plugin define (a third-party API key, say) would have reached the gate and the writer with a null value. Both clone and pull now name every withheld define in the run report, so the operator learns which values did not come down instead of finding out later that a plugin is silently unconfigured. scripts/wpconfig_block.py also gained its own rejection of a None define value as defence in depth, for a caller outside the normal classifier-to-writer path. See ADR-0020.
  • The resolved exclusion set left cache-plugin trees, backup-tool working directories, and the Extractor's own uploads staging in scope. On a live site that meant LiteSpeed's hashed CSS, BackWPup restore logs, and a previous failed job's .sealed.building were selected for clone — and the last of those can vanish mid-run because POST /extractions reclaims the directory the selection just named. ALWAYS_EXCLUDED now covers the known cache-plugin and backup-tool trees (wp-content/litespeed, wp-content/et-cache, wp-content/w3tc-*, wp-content/uploads/backwpup*) and the plugin's own three uploads directories as a self-reference, not a cache. The names that live inside the uploads directory — the backup tool's scratch and the plugin's three — are re-anchored on the classifications' uploads_prefix, so a site that moved its uploads directory (a non-default WP_CONTENT_DIR, an UPLOADS define) excludes them where they actually are and not only at the standard location, which is the layout classify.py already honours everywhere else. Glob-bearing directory prefixes match in filter_manifest.py and baseline_diff.py the same way, so a suffixed live directory is dropped and a sibling that does not fit the glob is kept; a consistency suite now pins those two matchers as one implementation, since a disagreement between them is exactly the deletion-diff poisoning issue #35 closed at the assembly end. The assembler is still the one source both the selection and the baseline consume.
  • A healthy job against an API-version-5 Extractor could abort as a false stall: below API version 6, progress.chunks_done does not exist, so stall detection fell back to the two coarse counters — but those move only when a whole table or a whole file finishes, and a 186-table site working through one large table stood still for minutes at a time on the unconditional 10-minute stall window. scripts/poll_extraction.py now widens the stall window to 40 minutes from the first poll that observes chunks_done absent, says so in the run's output, and reports the correct give-up minutes for whichever window actually fired. See ADR-0018.
  • Both poll-owning subagents shaped a FAILED verdict specifically so the orchestrator could clean up the still-active job afterwards, and the orchestrator was never told to do it: an unseal failure after a complete download, and an exhausted stall window, both left the production artifact or the active job in place on a live client site until the plugin's own TTL, and a failed bootstrap's cleartext dump of real user (and, when a recognised CRM was carried, subscriber) rows had nothing scheduled to report or remove it. Both SKILLs now carry a Closing out a failed phase subsection that cancels a job that never reached ready, consumes one that reached ready and downloaded but failed to unseal, and reports (never auto-consumes) one whose download itself failed — plus reports and, by default, deletes the diagnostic bootstrap dump. See ADR-0022.

0.9.0 – 2026-08-14

Added

  • docs/poll-discipline.md is the poll discipline's canonical statement, and the two consistency suites now read their pinned phrases from it instead of restating them in Python. The literals had been living inside tests/test_poll_discipline_consistency.py, which made a test file the source of truth for a product decision, and every rule stated only in prose was free to drift — as it did: the two poll-owning agents came to state the same new ban in different words, and the binding meant to catch that had to be loosened to accept both. Changing a rule is now one edit in the document plus the matching edit in each surface, and the suites refuse the change until every surface has followed. The surfaces still restate the discipline in full, because an agent definition loaded standalone must carry the whole rule set; what the document adds is one place where the wording is decided, and a pointer to it from each surface. A guard test pins the expected phrase names, so a phrase vanishing from the document reddens rather than quietly disabling its own enforcement.

Changed

  • agents/extract-transfer.md's two poll hard rules are split apart and carry the canonical wording verbatim, matching agents/discovery-classify.md word for word rather than merely in substance.

0.8.0 – 2026-08-14

Added

  • The Extractor version pin gains a verified ceiling alongside its floor: the skills are checked against API version ≤ 6, and a GET /status reporting more stops for the operator (aborting under --yes) rather than proceeding on an artifact contract this client has never been tested against. A floor alone cannot catch the hazard that actually occurred: API version 5 changed a table from exactly one sealed segment into one or more, and an un-updated client kept only each table's final slice — every other slice lost, with no error raised on either side and both repositories' suites green throughout. tests/test_api_version_ceiling_consistency.py binds the ceiling across every surface that states the pin, so raising it is a deliberate act of verification rather than an omission.

Changed

  • scripts/classify.py no longer stays silent about a form plugin's add-on it does not recognise. A slug carrying a recognised form-plugin prefix now earns one of three outcomes: a finding naming the service, silence when the remainder is a known non-service add-on (a paid tier, a PDF or signature feature), or an unidentified-service finding naming the add-on for the operator to check. safeteam.se runs ws-form-fluentcrm, which matched the ws-form prefix and no service suffix, so the mandatory form-to-service bullet in the risk warning — the one check standing between a local form submission and a live CRM — was dropped without a word (ADR-0009). The two registries now lag in opposite directions on purpose: extending the service registry improves the wording, extending the non-service registry removes noise, and an add-on in neither is reported rather than assumed harmless. The service registry also gained FluentCRM and eighteen other engines, and matching is on whole hyphen-delimited tokens, so ws-form-mailchimp-pro names Mailchimp and a remainder merely ending in a service's letters does not.
  • Both clone and pull list an unidentified-service finding exactly like a named one — an unrecognised service is one the operator has to check, not one to leave out.
  • The poll discipline's stall rule counts progress.chunks_done as an advance, alongside the state change and the two coarse counters it already watched (ADR-0018). Those two move only when a whole table or a whole file finishes, so a job slicing one large table reported 3/186 unchanged for minutes while perfectly healthy — indistinguishable from a wedged job, and worked around by widening the 10-minute stall window to 2400 s, which is how a genuinely dead job comes to take forty minutes to notice. Kntnt Extractor 0.5.0 adds chunks_done, which moves on every packaging chunk; the stall window therefore stays at 10 minutes. Against an Extractor below API version 6 the field is absent and the rule degrades to its previous form, with the fallback stated in the run's output rather than an absent field read as a stall.
  • agents/discovery-classify.md and agents/extract-transfer.md state that a poll loop is one blocking, self-terminating shell invocation, never one tool call per poll, and that the agent returns exactly once, with a verdict. discovery-classify returned three times mid-bootstrap saying it was still waiting — ~55k tokens each, no evidence block, every piece of real work already done — because its contract gave it no way to wait out a job taking minutes and no way to resume polling after a return. An exhausted budget is now a FAILED carrying the job id and last counters, so the orchestrator can consume or cancel the still-active job instead of leaving one wedged against the plugin's one-active-job rule.
  • discovery-classify creates its own working directory under the run's scratchpad and may name only artifacts it wrote there in its evidence block. In the same run it reported artifacts the orchestrator had produced, with matching SHA256s, while its own consume returned 404 because the orchestrator had already consumed the job — an evidence block is only evidence when nothing else could have written the files it describes.

Fixed

  • The container-format contract in docs/implementation-notes.md stated that slices concatenate to the whole-table dump byte for byte because the plugin cuts them on extended-INSERT boundaries. From Extractor API version 6 a slice is bounded by bytes as well as rows and a byte-bounded cut lands on the row that fills the budget, so the statement grouping differs from an unsliced dump's. The contract now states the property that actually holds and that reassembly relies on — every slice ends on a complete statement — and says explicitly that byte-identity is not among them. tests/test_unseal.py pins the uneven-statement shape that a byte budget produces.

0.7.0 – 2026-08-13

Added

  • scripts/unseal.py reads a container in which a table arrives as one or more consecutive segments sharing its name, concatenating them in index order into the reassembled dump. Kntnt Extractor 0.4.0 packages a large table in bounded row slices across as many ticks as it takes — one sealed segment per slice — because a table exceeding the host's memory_limit or max_execution_time was previously killed mid-tick and retried forever, making the site unclonable; the reader's positional check assumed exactly one segment per table and refused such a container outright. The newline terminator is applied once per table rather than once per segment, so a sliced dump is byte-identical to an unsliced one. The old one-segment shape is the one-slice case of the same rule and still reads, so the Extractor API floor stays at ≥ 2 and a client works against an un-upgraded production install. Validation stays strict: a structure-only table is still required to be exactly one segment, and a container that does not match the requested selection is still refused rather than reassembled into the wrong thing.

Fixed

  • The control channel no longer mistakes a failed authentication, or a cached error response, for a missing capability. Three changes, one root cause: an authentication attempt with a wrong username is treated as anonymous by WordPress — wp_authenticate_application_password() short-circuits silently on an unknown login and returns no error of its own — so the caller sees only rest_not_logged_in, and on a site behind a page cache that anonymous-looking refusal was cached and replayed to every later call, correct credentials included. (1) Every Extractor request now carries a unique _cb cache-buster (&-appended in the paged GET /files cursor loop, which already has a query string), so no intermediary can answer a call from a stored response. (2) The health check's step 2 establishes who is authenticated — from a credentialed GET /status's authenticated_as and capabilities on Extractor API version ≥ 4, and otherwise from a same-username/wrong-password probe whose incorrect_password proves the Authorization header arrives — before it interprets any refusal; the old inference from a 403 on /environment beside a 200 on /audit-log was unsound, since a cached refusal and a wrong username both mimic it exactly. (3) An authenticated response carrying x-litespeed-cache: hit, cf-cache-status: HIT, x-cache: HIT, x-proxy-cache: HIT, or a non-zero age: now raises a loud, specific diagnostic and aborts, instead of being read as a genuine answer. docs/implementation-notes.md gains a symptom-to-cause troubleshooting table for the three indistinguishable causes.
  • The Keychain account convention <wp-user>@<host> is now documented as splitting on the last @, never the first. The WordPress user_login is frequently an email address in its own right — thomas@kntnt.com@safeteam.se is a real, valid account name — so a first-@ split produces the login thomas, which does not exist, and authenticates as nobody without reporting any error. The documentation now states explicitly that the first component is the user_login (often an email), where an operator finds theirs (Users → Profile → Username), and that a credentialed GET /status reports it back as authenticated_as.

0.6.0 – 2026-07-24

Added

  • A new deterministic helper, scripts/wpconfig_block.py, now owns the wp-config.php marked-block write that clone/pull §9.4 previously prescribed as hand surgery. It takes the current config text, the resolved portable defines, the table prefix, and the cron decision, and returns the new full text with the skills' marked block written (delimited by // BEGIN kntnt-wp-skills / // END kntnt-wp-skills, replaced in place when present or inserted above the /* That's all, stop editing! line when absent) and every scaffold collision it supersedes removed. The collision set is computed — the portable defines plus DISABLE_WP_CRON intersected with whatever the scaffold actually shipped — never a hard-coded name list: the second live smoke test's scaffold carried five colliding defines (DISABLE_WP_CRON, EMPTY_TRASH_DAYS, WP_DEBUG, WP_DEBUG_LOG, WP_DEBUG_DISPLAY) where the SKILL prose named two, and a repeated define() on the same constant fatals. Both clone/pull §9.4 now invoke the helper and lint the result with ddev exec php -l wp-config.php instead of describing the edit by hand. The helper fails loud at the boundary on the malformed-input classes that would emit a duplicate define() on one constant — a runtime fatal php -l cannot catch: a name repeated within the input defines, DISABLE_WP_CRON smuggled into the defines (its write is solely the cron field's job), a define name outside the PHP constant-identifier shape (closing a PHP-injection path through a quote-bearing name from production's config), and a non-finite float value (NaN/Infinity, which json.loads accepts and would render as a bare undefined-constant fetch). (#42).
  • A recognised CRM/mass-mailer's subscriber tables now get their own carry/empty privacy gate, default empty — a sibling to the user_submissions gate and a sixth classification family. scripts/classify.py tags FluentCRM, MailPoet, The Newsletter Plugin, and Mailster subscriber/address stores (fc_subscribers and its family, fc_campaign_emails, fc_campaign_url_metrics, fc_funnel_subscribers, fc_funnel_metrics, mailpoet_subscriber*, mailster_subscriber*, mailster_queue, The Newsletter Plugin's exact-matched newsletter table plus newsletter_sent*/newsletter_stats*/newsletter_user*) as crm_subscribers, while the engine's definitions (campaigns, funnels, lists, tags, terms, meta, url stores, and Newsletter's newsletter_emails campaign bodies) carry in full as site config. Every engine in bootstrap_parse.py's MAILER_ENGINES recognition registry (FluentCRM / MailPoet / The Newsletter Plugin) now has a gated subscriber store — pinned by a drift test — so a recognised mailer's addresses can never carry in full; Mailster is gated in addition, making the gated set a superset of the recognised engines. scripts/resolve_plan.py adds the crm_subscribers decision and folds each privacy gate's resolved carry independently into db_table_content, so one gate's carry never disturbs the other's tables. Closes the gap the second live smoke test exposed, where 584 real FluentCRM subscribers transferred in full and standing funnel automations could mail them from a dev copy under the mail=live + cron-runs defaults — a risk the mass-send valve does not catch (ADR-0019, #39).

Changed

  • The Application Password's home is now a documented convention instead of living only in the operator's head: macOS Keychain, service kntnt-extractor-app-password, account <wp-user>@<host>, retrieved with security find-generic-password -s ... -a ... -w inside a subshell, with the KNTNT_EXTRACTOR_APP_PASSWORD environment variable as the portable non-macOS fallback. The health check now resolves and verifies the credential itself (§1) and fails with the exact remediation ("create a Keychain item: service ..., account ...") when it is absent, instead of presuming "the configured" password exists. The three subagent task envelopes (discovery-classify, extract-transfer, manifest-baseline-diff) now take a credential reference — a Keychain service+account or an env-var name — resolved by the calling code inside each authenticated call's own subshell, so the secret's value never transits the orchestrating agent's own context (#44).

Fixed

  • The heavy-blob heuristic is no longer uploads-scoped: a heavy stray directory anywhere else — the 7.6 GB install-root 2026/ directory the second live smoke test found, which the heavy_blobs=exclude default could not reach and would transfer silently — is now flagged too. scripts/discovery.py adds a root and a content breakdown beside the existing uploads one, each summing its top-level subdirectories (loose files are not directories and do not count), and scripts/classify.py runs a deliberately simpler floor-only rule over them: any non-standard directory at or above the 1 GiB floor is flagged, no median-outlier test, since at these levels the peer population is small and dominated by standard directories. The standard sets are derived from the document's own content and uploads locations, so a non-default content dir or uploads dir is honoured and its payload never flagged; when the content path is absent the default wp-content segment is assumed rather than exposing the payload tree. All flags merge into the single blobs.flagged list, so the existing heavy_blobs gate and scripts/build_exclusions.py keep the newly flagged directory out of the transfer by default and give the operator a lever over it, with no gate or exclusion-assembler change. A pre-existing document without the new sections yields exactly today's uploads flags (#38).

  • scripts/unseal.py's seal mode (the development/test round-trip aid) still reached into each segment with bare segment["name"] / segment["data"] indexing, so a malformed segment raised an uncaught KeyError traceback instead of the clean unseal.py: missing required config key: 'name' diagnostic the rest of the helper emits — a residue of #47, which deliberately narrowed main()'s except to stop masking stray KeyErrors but left these two accesses unguarded. Both per-segment reads now go through the same _required accessor, so a missing key fails loud and uniform like every other required-key check. seal is off the clone/pull hot path — the plugin seals in production — so the impact was confined to the test aid (#47).

  • Clone §3 accepted the plan at the end of its gate walk but wrote it out immediately, before §4 scaffolds the site directory <directory_name>/ that plan belongs in — so a literal top-to-bottom implementation wrote the bare .kntnt-wp-skills.json one level up, in the operator's invocation cwd. That mismatch guaranteed two false smoke FAILs (check_saved_plan_present, check_baseline_present, both anchored at the site directory) and left a future pull unable to find its plan or baseline. §3 now defers the write; §4's final bullet, after the restart onto the corrected engine, persists the accepted plan into <directory_name>/.kntnt-wp-skills.json — pre-creating the directory in §3 was rejected, since mkwp (≥ 1.8.1) refuses to scaffold into a path that already exists. "The local project root" — the site directory <directory_name>/ both persistence artifacts live in — is now defined once, in CONTEXT.md, and referenced consistently from docs/spec.md and both skills (#40).

  • The poll discipline no longer treats a single spurious 404 on GET /extractions/{id} as terminal. The second live smoke test of the Extractor cutover proved the bare 404-is-terminal rule wrong in the field: the status endpoint returned 404 twice mid-job while the job was alive and progressing, a server-side non-atomic job.json rewrite race (Kntnt/kntnt-extractor#20). A 404 is now terminal only when confirmed vanished: the first 404 is treated as a transport-class fault, retried under the existing 30 s / 60 s backoff, and cross-checked against GET /extractions; the job counts as vanished only when the re-poll also 404s and the id is absent from the listing — otherwise the blip is logged and retried, and polling continues within budget. ADR-0018, both clone/pull SKILLs, agents/extract-transfer.md, and agents/discovery-classify.md's compact reference now state the rule identically (#41, refines ADR-0018 / #34).

  • Every documented keygen invocation was the bare uv run scripts/unseal.py keygen, but the helper always parses a JSON envelope from stdin and keygen requires a caller-chosen private_key_path (the private key must land outside the docroot), so following the docs verbatim crashed at the health-check preflight with a generic JSON-parse error. All eight call sites — three each in the clone and pull SKILLs, plus agents/discovery-classify.md and agents/extract-transfer.md — now pipe the real envelope echo '{"private_key_path": "..."}' | uv run scripts/unseal.py keygen, and keygen fed empty or malformed stdin now exits non-zero with a self-documenting unseal.py: keygen requires JSON on stdin: {"private_key_path": "..."} instead of a bare parse error. The stdin/stdout contract is now pinned and guarded by tests/test_unseal_contract_consistency.py. Found on the second live smoke test of the Extractor cutover (#43).

  • Four documentation drifts surfaced by the second live smoke test, none behavioural but each misleading a literal reader: docs/implementation-notes.md's endpoint example used the non-existent /wp-json/kntnt-extractor/v2 (the 2 is the response-body api_version, not the REST namespace, which is /v1); its "saved plan — illustrative shape" section still showed the superseded nested camelCase structure instead of the flat snake_case shape scripts/resolve_plan.py actually emits; the clone/pull SKILLs described their companion docs as living "alongside this file" when they sit at the repo root; and agents/discovery-classify.md named the POST /extractions tables in prose but never gave the request-body shape. All four are corrected, and a new guard test binds the saved-plan example to resolve_plan.py so it cannot silently drift again (#45).

  • scripts/discovery.py reported WordPress's empty-directory guard file wp-content/themes/index.php as a theme (the live smoke returned themes: ["index.php", "ollie"]), because _relative_children took the first path segment under a prefix unconditionally and never distinguished a file from a directory. The manifest holds files only, so directory-ness is now inferred — a segment counts as a child directory only when at least one manifest path continues past it with a further / — and both derive_themes and derive_uploads_subdirectories drop bare files at the prefix level, closing the same latent bug in the uploads derivation before the planned root-scope blob heuristic could inherit it (#46).

  • scripts/unseal.py's unseal mode printed a raw quoted KeyError (e.g. unseal.py: 'sql_path') and exited 0 when a required config key was missing — a silent failure in the very step that produces the dump everything downstream imports, which an exit-code-checking orchestrator would read as success even though nothing was unsealed. A missing required key now raises a clear missing required config key: 'sql_path' diagnostic and exits non-zero, writing no .sql file; main()'s exception handling was narrowed so a stray KeyError is no longer masked as a user error. Found on the second live smoke test of the Extractor cutover (#47).

  • scripts/build_selection.py required its files input as a flat list of path strings, but clone §5 feeds it scripts/filter_manifest.py's output — a {entries: [{path, size, mtime}], scope: {...}} object — so a literal implementation of the chained steps failed with files must be a list, got dict and forced an undocumented [.entries[].path] transform between them. build_selection.py now also accepts the {entries: [...]} form directly, extracting the paths itself, so the helper chain composes with no prose glue; the flat-list form still works (#48).

  • scripts/wpconfig_block.py required its defines input as [{name, value}] objects, but the §9.4 pipeline feeds it the resolved wp_config_defines decision — which scripts/resolve_plan.py deliberately emits as names only (their values are re-fetched from live state every run), so a literal implementation piped a name list into the writer and aborted every clone/pull that ported a define with defines[0] must be an object, got str. The only join the SKILL prose offered would have had an agent hand-improvise the name→value merge on a fatal-sensitive file — and piping classifications.defines.portable wholesale to sidestep it would silently port the very defines the operator deselected at the wp_config_defines gate, defeating the gate. The writer now owns the join deterministically: defines takes the classifier's portable [{name, value}] records as the value source, an optional select takes the resolver's kept-name list as the gate selection, and the helper writes only the selected names each valued from its record — so a deselected define is never ported, a selected name absent from the records fails loud as a corrupt join, and an absent select writes every record (unchanged for a caller that already filtered). Both clone §9.4 and pull §9.4 now pass the two artifacts as they stand and forbid the hand-join. No test spanned the resolve_plan→wpconfig_block seam before; tests/test_wpconfig_block_resolve_seam.py now drives the real helpers end to end (#42).

  • The discovery-classify phase left its unsealed bootstrap dump — 14 MB of cleartext user and subscriber rows — in the scratchpad after scripts/bootstrap_parse.py had consumed it, alongside the sealed container; harmless under an ephemeral /private/tmp scratchpad but unmanaged PII at rest once the operator points the scratchpad at a persistent directory. bootstrap_parse.py now deletes the unsealed dump, the sealed container, and the run's private key immediately after parsing — the local analogue of the Extractor's own POST /consume — with a best-effort sweep so one failed unlink never blocks the rest, and the subagent contract records the discipline. The main extraction's dump keeps its longer, import-scoped lifetime. Found on the second live smoke test of the Extractor cutover (#49).

0.5.0 – 2026-07-23

Added

  • A single deterministic assembler, scripts/build_exclusions.py, now builds the resolved exclusion set — the union of a canonical always-excluded constant (ALWAYS_EXCLUDED: the configuration file, the WordPress drop-ins, the debug log, the cache dir, and the upgrade dirs) with the decision-gated DB-known thumbnails, flagged heavy blobs, and — under --exclude-media — the whole uploads tree. Both consumers that previously hand-assembled the set from the same ingredients now obtain it from this one helper: the extraction file selection (clone §5) and the baseline manifest (clone §9.12, pull's diff), so the two are byte-identical and a scope change can never poison the pull deletion diff. The always-excluded paths exist exactly once, in the constant — every prose reference points at it rather than re-enumerating — and scripts/classify.py now emits uploads_prefix so a media exclusion can be anchored at the WordPress root. Structured so the two blocked child issues can extend the constant: credential-bearing backup patterns (#36) and the WordPress core tree (#37) (#35).

Fixed

  • ALWAYS_EXCLUDED now covers the credential-bearing pattern family that previously sailed through unfiltered: every backup, editor-swap, and variant sibling of wp-config.php (wp-config.php.*, wp-config.php~, .wp-config.php.sw?, wp-config-*.php — with wp-config-sample.php, WordPress' own placeholder-only template, explicitly carved back out), .env/.env.* anywhere in the tree, and root-level database dumps (*.sql, *.sql.gz, *.sql.zip) and key material (*.pem, *.key, id_rsa*). scripts/filter_manifest.py and scripts/baseline_diff.py's is_excluded now match a glob-bearing exclusion entry case-insensitively, anchored either at the install root or anywhere in the tree, alongside the existing exact-or-descendant prefix matching. Found on the second live smoke test of the Extractor cutover, where two live wp-config.php.bak-* files carrying the complete secret family in clear text sailed through the exclusion set and landed in local staging (#36).
  • ALWAYS_EXCLUDED now also covers the whole WordPress core tree — wp-admin/, wp-includes/, and the root-level core PHP files (index.php, wp-login.php, wp-settings.php, xmlrpc.php, and the rest of the install-root PHP core ships) — so it is never selected for extraction: production's copy is always byte-identical to what clone's scaffold just installed, so transferring it was pure waste. scripts/filter_manifest.py and scripts/baseline_diff.py's is_excluded now also match a bare top-level directory prefix (no / of its own, like wp-admin) against itself and its descendants, not only against a root-anchored file pattern. The specification, the clone/pull skills, and the implementation notes no longer claim GET /files returns a content-scoped tree — it is install-root-wide and carries core too — and now state explicitly that core is kept out by this client-side exclusion set, never by the server. Found on the second live smoke test of the Extractor cutover, where 3,485 core files were extracted and merged over the byte-identical core the scaffold had just installed (#37).

0.4.1 – 2026-07-23

Fixed

  • CONTEXT.md's Exposure window glossary entry named the wrong endpoint for the happy-path close: consuming a finished job is POST /extractions/{id}/consume, not DELETE /extractions/{id} — the latter is only for cancelling a stranded or aborted job. A leftover from the 0.4.0 cutover, where the same mix-up was fixed in the spec and the implementation notes but the glossary was missed.
  • The extraction poll no longer fails on a single stalled status request, and the health-check download preflight now exercises the continuation path a one-chunk job never touches. All three poll loops — preflight, bootstrap, and main extraction — follow one pinned discipline: a steady 15 s cadence with a 120 s per-request timeout, retry with backoff on a transport timeout, connection error, or 5xx, progress reported between polls, and failure only on a failed state, a vanished job, a 10-minute no-progress stall, or the loop's overall wall-clock budget (10/15/60 minutes respectively). The preflight submits exactly two structure-only tables ({table_prefix}options, {table_prefix}users) and times create-to-ready: ≤ 90 s passes silently, a slower-but-completing probe warns loudly that the host advances jobs at backstop cadence and gates on the operator, and a probe that misses the budget aborts with that warning as the remediation. Both faults surfaced on the first live smoke of the Extractor cutover, where two zero-byte poll responses were read as failure while the job completed fine, and a one-chunk preflight passed on a host whose continuation loopback was dead (#34, ADR-0018).

0.4.0 – 2026-07-22

Changed

  • The control channel is now the Kntnt Extractor plugin's REST API instead of the Novamira MCP execute-php channel. clone and pull reach production over HTTPS as a real WordPress user authenticated with an Application Password (HTTP basic auth), and every data call is gated on both the kntnt_extractor_operate and manage_options capabilities. Production now requires Kntnt Extractor (REST API version ≥ 2) installed and enabled in place of the Novamira MCP server; there is no arbitrary PHP or WP-CLI execution and no SSH path (#24, ADR-0016, ADR-0017).
  • Discovery is now a two-phase reconstruction over that surface: GET /environment, GET /tables, and the paged GET /files supply the runtime, table, and file facts, and a cheap bootstrap extraction — parsed client-side — supplies the attachment metadata, entity counts, and mass-send poised-campaign scan that shape the main extraction, replacing the single server-side execute-php discovery scan.
  • The extraction, per-segment sealing, one-time download link, and cleanup are now owned by the plugin's own background job; the engine only builds the selection, submits it, polls the job to completion, downloads, and unseals the sealed container to the run's ephemeral X25519 key pair. Data comes down sealed under authenticated encryption rather than a passphrase-encrypted archive, so no passphrase is ever generated or web-served, and the database password and the auth keys/salts/nonces are masked server-side and never enter model context.
  • The mkwp skill now recommends installing Kntnt Extractor by default on a freshly scaffolded site — resolving kntnt-extractor-*.zip from the plugin's latest GitHub release, with a graceful drop on failure — so the new site is already reachable by a later clone/pull.
  • The required CLI tool floor is now uv, jq, and curl. shasum/sha256sum and openssl are no longer needed: integrity is authenticated encryption verified when the container unseals, and the unseal is a uv-run helper with pynacl as an inline dependency.

Removed

  • The Novamira MCP control channel and its execute-php/run-wp-cli abilities, the SSH path, and the entire client-side pack machinery — the generated pack.sh, the server-side passphrase, the openssl encryption, the outside-docroot working directory, and the docroot download directory — all superseded by the plugin-owned background extraction (#24, ADR-0016, ADR-0017).

0.3.2 – 2026-07-20

Fixed

  • Documentation reconciled with the code across the specification, the manual pages, and templates/README.md, with no change to behaviour: templates/README.md now documents kntnt-wp-skills-mailpit.php as the local capture mu-plugin the engine drops into the copy — not an execute-php control-channel payload — and scopes the "no declare(strict_types=1), no namespace" convention to the payload table so it no longer contradicts that file; docs/spec.md lists the fifth build-ollie-site helper (instantiate_patterns.py) and the local manifest-filtering helper (filter_manifest.py), and no longer claims the saved plan records the empty-table classification patterns (they are re-derived classify.py constants, never persisted); the clone/pull manual pages describe the --include-media/--exclude-media default as the built-in constant it is rather than a discovery-derived one; README.md notes the production-side pack script is generated at runtime by scripts/pack_script.py rather than shipped as a static shell template; and docs/implementation-notes.md corrects the Mailpit host to 127.0.0.1:1025 and adds @@collation_database to the discovery queries. The build-ollie-site reference and helper docstrings are likewise corrected (M<n> module tags label sections rather than detect components; the check_contrast.py and dump_ground_truth.py usage strings).

0.3.1 – 2026-07-20

Fixed

  • The entity-count smoke-test check no longer produces a false-positive FAIL on a site whose active plugin filters the main query. scripts/smoke_test.py's check_entity_counts now derives its live counts with raw SQL over ddev wp db query — the same unfiltered COUNT(*) templates/discovery.php builds the expectation from, mirrored clause-for-clause per entity (published posts, published pages, attachments with trash/auto-draft excluded, users) against the site's real table prefix — rather than through wp post list / wp user list, which go via WP_Query and are silently narrowed by any active main-query-filtering plugin (Bogo, and the broader membership/geo-restriction/post-visibility class); a complete clone of such a site tripped the check on every run (#33).
  • Bogo (bogo/bogo.php) is now recognised as a multilingual plugin by scripts/discovery.py's detect_multilingual, so the localised-subpage rewrite-flush smoke-test canary is armed on Bogo sites instead of being silently skipped (#33).

0.3.0 – 2026-07-20

Added

  • build-ollie-site — a fourth, standalone skill that builds a coherent, token-correct WordPress site on the Ollie block theme from a design system and a set of mockups, bottom-up by Atomic Design. It shares none of the transfer engine's machinery — no production, no Novamira, no recommendation-gate backbone — and works through gated phases where a later layer is never built before the one below it is locked and verified: ground truth (the tokens the install actually resolves and the patterns it already registers), pattern cartography (the pattern taxonomy derived from the mockups' structure, human-in-the-loop, into a confirmed manifest), the token foundation in an Ollie child theme's theme.json, component patterns (Inserter: no molecules), section patterns (full-width bands composing components by wp:pattern reference inside the pattern files), and pages built as stamped, expanded instances of the section patterns — a slug reference can neither carry per-page content nor survive the editor, so every page band is a traceable instance (metadata.patternSlug) filled with that page's own content: supplied copy first, else the mockup's, else flagged placeholders. The manifest classifies each section's content as fixed or per-page, which decides whether a later structure fix may rewrite built instances blindly or only report them. Ollie supplies only tokens and global styles; the site's patterns are the operator's own, verified against what the install actually emits rather than Ollie's prose.
  • Five helper scripts for build-ollie-site under skills/build-ollie-site/scripts/: dump_ground_truth.py (resolved tokens and registered patterns from the live install), mine_structures.py (the pattern-cartography first pass, grouping mockup bands by a content-blind structural signature), lint_markup.py (the on-system gate — no hardcoded values including raw border radii, no invented token slugs, no dangling wp:pattern references, with a lint:allow pragma that downgrades a manifest-sanctioned one-off literal to a visible note), check_contrast.py (the WCAG-AA gate for the design system's colour pairings), and instantiate_patterns.py (pattern expansion onto pages: recursive flatten with provenance stamping, the page-provenance audit, a report-first reapply path for structure fixes on built pages, and a live do_blocks() render check that works for Inserter: no patterns).
  • Being gate-driven and human-in-the-loop, build-ollie-site carries no operational flags — only the help gate — and its scripts/flags.py registry entry, its manual page, and the help/docs consistency test's per-skill binding reflect that.

0.2.0 – 2026-07-20

Added

  • Plugin help: the /kntnt-wp-skills:help command and a full manual page for each skill (clone, pull) and for the help command itself, so /kntnt-wp-skills:help help documents the reader (without listing help among the skills).
  • clone — create a fresh local DDEV copy of a production WordPress site in an empty directory: scaffolded at production's core version, its table prefix adopted and DDEV's database engine and PHP version pinned to production's, then the packed database and files imported and localised.
  • pull — refresh an existing local copy, transferring only the files new or changed since the last sync while always dumping the database in full, with a rollback backup taken before the destructive import.
  • The shared transfer engine, reached solely over the Novamira MCP (no SSH): a mandatory health check — starting with a dependency step that verifies ddev and its container backend, the required CLI tools (uv, jq, curl, shasum/sha256sum, openssl), mkwp for clone (via the shared scripts/mkwp_guard.py guard), and the target site's connected Novamira server with its full discover-abilities inventory, stopping early with per-dependency remediation on the first thing missing — a single read-only discovery scan, a background pack that dumps, archives, and encrypts outside the docroot, download-and-verify with immediate remote cleanup, and a deterministic post-clone/pull smoke test of the finished copy against an expectations file (scripts/smoke_test.py), runnable standalone and with a --generate mode that derives an expectations file from a discovery document.
  • Recommendation-driven decisions behind accept-or-override gates, resolved over layered defaults (built-in < live derivation < saved config < this-run answer); --yes runs unattended and records every decision, and a saved plan (.kntnt-wp-skills.json) collapses a repeat run to a single replay gate.
  • Discovery-derived recommendations: wp-config define porting with secrets auto-excluded, operational tables carried empty, heavy-blob and generated-thumbnail exclusion, and the object-cache drop-in ownership rule at pull.
  • After import, the derived data the transfer deliberately drops is rebuilt locally so the copy is fully functional: generated thumbnails are regenerated from their originals, and a Relevanssi or SearchWP search index is rebuilt in place through the plugin's own WP-CLI command when the install provides one — otherwise the run reports a manual-rebuild instruction rather than shipping the index (ADR-0011, ADR-0015).
  • A fifth table classification family, user_submissions (WS Form, Fluent Forms, Formidable, WPForms, Gravity Forms), with its own carry/empty gate defaulting to empty — the most privacy-sensitive data the transfer handles is excluded by default rather than silently emptied alongside the operational tables (ADR-0014).
  • Safety behaviours: user data encrypted in transit and deleted from production once verified, deletion mirroring off by default and always to a timestamped trash, and a mass-send valve that keeps the real mailer live by default but flips to Mailpit capture on a poised campaign (--live-mail / --capture-mail pin it), with the risk warning always emitted.
  • The minimal flag surface — --yes, --include-media / --exclude-media, --include-blobs, --live-mail / --capture-mail, --no-cron, --regenerate-all, and the help forms — as a single canonical registry.
  • Automated test suite (pytest via uv) over the deterministic helper seam, with a help/docs consistency test binding the manual pages, the flag registry, and the README links together.
  • Four pinned subagents shipped under agents/ (discovery-classify, pack-transfer, manifest-baseline-diff, thumbnail-smoke-test, each with model and reasoning effort fixed in its frontmatter) that both skills delegate their heaviest, noisiest phases to, so the orchestrating agent's own context stays clear of MCP round-trip logs, curl/checksum output, and thumbnail-regeneration warning spam; each returns a structured evidence block (exit codes, artifact paths and SHA256, row/file counts, a DONE/FAILED marker) the orchestrator validates with its own cheap deterministic spot checks rather than trusting a second LLM's prose.
  • mkwp — a third, standalone skill that scaffolds a brand-new local WordPress site by driving the mkwp command directly: no production, no Novamira, no transfer engine. It derives site-identity and content flags (--dirname, --directory, --title, --email/--user, --language, --php, --wp, --themes/--plugins/--mu-plugins) from context where possible and otherwise presents them as recommendation gates, recommends installing Novamira by default so the site is already reachable by a later clone/pull (ADR-0001), and never gathers or echoes a password — that stays mkwp's own random generation. A shared version guard (scripts/mkwp_guard.py) verifies the local mkwp on PATH is at least 1.8.1 — the release that fixes Kntnt/mkwp#3, where an earlier mkwp broke the scaffold outright whenever --dirname diverged from the site's name — before scaffolding.
  • The flag registry (scripts/flags.py) now keys its surface per skill instead of one flattened set, since mkwp's flags have nothing to do with clone/pull's; the help/docs consistency test binds each skill's manual page to its own registry entry accordingly.

0.1.0 – 2026-07-18

Added

  • Initial release.