All notable changes to this project are documented here. The format follows Keep a Changelog and the project uses Semantic Versioning.
build-ollie-siteis 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 theskills/<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.pyrelative 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 holdingSKILL.md, wherever it was installed), and the README documents both channels. Nothing about the skill's behaviour under the plugin changes.- The transfer engine —
cloneandpull— is installable as a standalone skill pair as well, sonpx skills add Kntnt/kntnt-wp-skillsnow 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.pyruns 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 genuineError:or fatal line.smoke_test.pygained--log <path>, which writes the full report there and prints only the verdict, the pass/fail/attention/skip counts, and thefail/attentionfindings;poll_extraction.pygained 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. mkwpis installable as a standalone skill too, sonpx skills add Kntnt/kntnt-wp-skillsnow carries it alongsidebuild-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) andclassify.py(the directory-name derivation it shares withclone), and both now ship inside the skill. Its help gate degrades exactly asbuild-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, soclone,pull, and the bundled subagents can reach intomkwpsafely; 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.phpit was still missing, and the rest of OpenSSH's default key basenames.skills/clone/scripts/build_exclusions.pynow 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 existingwp-config.php.*catcher never saw them. The root key-material family gainedid_dsa*,id_ecdsa*, andid_ed25519*besideid_rsa*, covering the-skhardware-token variants through the same prefix and, deliberately, the.pubsibling 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 barewp-config.*, which would reach further than the shape being closed, andwp-config-sample.php— WordPress' own placeholder-only template — stays transferable, pinned by its own test alongside the false-positive controls that keep a theme'sconfig.oldand an uploadedid_ed25519-tutorial.pngin 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.pycarried.wp-config.php.sw?—swplus one character, and nothing else — so.wp-config.phpitself 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.phpand.wp-config.php.*, which subsume it. The Kntnt Extractor's restricted-path policy is widening to/^\.wp-config\.php(\..+)?$/i, and it refuses the wholePOST /extractionswhen 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 barewp-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.pyanchored all sixteenwp-configentries 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 againstbasename()at any depth since 0.3.0 and refuses the wholePOST /extractionswhen a selection names one. A site carrying a copy underwp-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 reorderedwp-config.bak/.old/.orig/.savenames with or without a trailing.php) now carry the**/marker the.envfamily has used since #36, so each is matched by basename at any depth. The one broadwp-config-*.phpvariant 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 thewp-config-sample.phpcarve-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 nestedwp-config-local.phpis 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.mdgives it verbatim), with strict mode and the checked directories declared in a new rootpyproject.tomlso 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 acrossplans/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 homespytest.iniputs on the import path), andskills/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 — withnacl.bindingsinvisible, mypy invents ano-any-returnin the unseal helper that does not exist, so--ignore-missing-importswould manufacture a finding while blinding the check at the only boundary a real one could hide behind; the pin belongs toskills/clone/scripts/unseal.py's own PEP 723 header, which mypy cannot read, andtests/test_type_check_consistency.pyholds 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.pynarrowing the job-listing response before reading it — theelse:arm of atryis where mypy cannot see that the failure path assignedNone, so it becomes an explicitis not Noneguard entered under exactly the same condition — andskills/clone/scripts/resolve_plan.pymaking a comparison overdict[str, Any]concrete withbool(...), a no-op at runtime. No helper's behaviour changes, every helper keeps its originalrequires-pythonfloor anddependencieslist, and nothing is silenced with atype: 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 bareruff checkreports 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.mdandplans/README.mdall name the same command, anddocs/spec.mdstates the same division while pointing atCONTRIBUTING.mdfor 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_sizeonPOST /extractions, andgrep -rn chunk_sizeover 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.pygains achunk_sizedecision with a built-in default of262144(256 KB, the one value measured to complete a real clone) over an optional saved-planchunk_sizekey, and an ordinary gate in the ordered walk like every other decision (ADR-0005) — no new seam, and no new flag, since--yesalready 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 a422 kntnt_extractor_malformed_bodyhours 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'shonourslist names it — the same discipline thestate=allsweep follows, since the member is additive and an Extractor that does not know it ignores it;skills/clone/scripts/discovery.pycarries that list through the run as an optional sibling ofapi_version, read from the authenticatedGET /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 sidedocs/spec.mdand everySKILL.mdit 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 inAGENTS.md, which is always loaded and authoritative, rather than inCONTRIBUTING.md(a page a human reads once at setup) ordocs/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 totests/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 outsidetests/test_type_check_consistency.py's list, so changingskills/clone/scripts/unseal.py'spynaclpin 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-importsin order to refuse it, which is precisely what that check forbids an instruction from doing. - The
mkwpfloor-version suite no longer mistakes a package pin for a stalemkwpfloor.tests/test_mkwp_floor_version_consistency.pybans the pre-fixmkwpfloor's version string from seven documents, and since #74 thepynaclpin 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 withmkwp. #74 worked around it by describing the pin instead of quoting it. Everyname==versionpin 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.
scripts/classify.pyandscripts/mkwp_guard.pymoved toskills/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, whichdocs/spec.mdnow 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/andskills/clone/templates/. Not a line of any script changed — they were already self-contained — but the paths every surface names did:clonenames its own (scripts/unseal.py),pullnames 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 onlyscripts/help.py, the manual-page renderer no skill directory could carry, which every skill's help gate reaches through the same checked, degrading fallbackbuild-ollie-siteestablished. 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 underagents/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, anddocs/poll-discipline.mdnames 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.
sampleUrlswas 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 spuriousFAILEDon the 2026-08-19 production clone.skills/clone/scripts/smoke_test.pynow 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 earnsattentionrather than afail, 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--logsummary, and thethumbnail-smoke-testrole's evidence block all carryderived/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).
- 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.mdsays a reader MUST NOT do —disclosureis the only reliable signal, and it has been on everydefinesrecord since Extractor API version 7, which production now runs. Two things followed. A define on the server's allow-list whose real value isnullwas 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 thoughsecret(a name shaped like a credential, which generally should stay withheld) andnot_allow_listed(a name the site operator can opt in on production through the Extractor'sKNTNT_EXTRACTOR_DISCLOSABLE_DEFINESconstant) call for opposite advice.skills/clone/scripts/discovery.pynow 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 — andskills/mkwp/scripts/classify.pyasks 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 areason, 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 disclosednullis still never written, becausedefine('NAME', null);makesdefined('NAME')reporttrueand 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-transferowned the whole phase — submit, poll, download, unseal, consume — and was instructed to wait inside one blockingpoll_extraction.pyinvocation 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 isDELETE, only an unwritten manual state check stopped a healthy job being cancelled. The phase is now split at the poll boundary: the newextract-submitrole submits and writes the accepted job to<scratchpad>/extract-job.jsonbefore anything waits on it, both skills poll it themselves as their own tracked background job — never delegated, never detached — with the verdict captured toextract-poll.jsonand the exit status toextract-poll.exitbeside it, andextract-transferis entered on an already-readyjob 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.pyis 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 asFAILEDwhen the result arrives without its evidence block, and the close-out mapped aFAILEDwhose stall window was exhausted toDELETE /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 aDELETEon 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_doneclimbing) 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-queryGET /extractions/{id}once before choosing a close-out case at all, on anyFAILEDverdict 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. Theextract-transferrole scopes its verdict to match —FAILEDbinds 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'sconsumeafter a complete download. See ADR-0022. - A
consumethe Extractor refuses because the job's lock is held no longer fails a finished run. Both purging routes —POST /extractions/{id}/consumeandDELETE /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 answer409 kntnt_extractor_lockedwhen 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 alwaysFAILEDturned 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 onetick_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 ownunsealed_consume_lockedfailure phase and closed out as a complete transfer whose artifact awaits its TTL, not as a failed one. The same409on 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 /extractionswhen a selection names a path its own restricted-path policy covers —422 kntnt_extractor_restricted_path, every offender indata.paths— andgrep -rn "kntnt_extractor_restricted_path"over this repository returned nothing at all: there was no handling anywhere, and the submission step described422only 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 awp-config.oldor anid_ed25519. Theextract-transferrole and both SKILLs now report every path the server named, drop exactly those from the selection'sfiles, 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 asrestricted_pathsand named in the run report besideskipped_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 anapi_versionbump. 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: 48578against 48,552 distinct files on disk: twenty-six pairs of paths that differ only by Unicode normalisation —åas one code point on one and asaplus 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.pynow groups the file list on its NFC form before writing and reports every group larger than one asnormalisation_collisions, naming each spelling, and counts the distinct files that actually landed by their own(device, inode)identity asfiles_landedbesidefiles_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.webpderivative 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_versionoftruepassed the type check written to reject it.boolsubclassesintin Python, soisinstance(True, int)is true and every integer field inskills/clone/scripts/discovery.pytook a boolean for a number — the required fields, the optional ones, the entity counts' own check, and the file manifest's tolerant size read. Atrueapi_versionwould have ridden into the canonical document and been compared numerically downstream as a 1, and a table'sbytesoftruewould 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_typeis now the one place the module decides what a type check means: a field declaredintrefuses both booleans, every other declared type keeps plainisinstancesemantics, and the refusal keeps the shape and thegot booldiagnostic every other type mismatch produces. A field declaredboolis deliberately unaffected. The question had been reached once before in this repository and answered locally —wpconfig_block.pyalready orders itsboolcheck ahead of itsintcheck 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
sizeand a subdirectory'ssize_bytesboth passed a bareisinstance(value, int), whichboolsubclasses its way through, soskills/clone/scripts/baseline_diff.pyandskills/mkwp/scripts/classify.pyacceptedtruewhere every other type mismatch would have failed loudly. One of them was live rather than latent: the baseline-diff helper'smtimereader checks(int, float)and returnsfloat(True) == 1.0, andmtimeis half the size+mtime quick-check — a booleanmtimeread 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_typepredicate, identical in intent to the assembler's, and its boundary helpers route through it; themtimereader cannot, since a predicate keyed onexpected is intcannot speak for a tuple of accepted types, so it refuses a boolean explicitly ahead of the numeric check — the orderingwpconfig_block.pyhas 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 portablemkwpclassifier importing fromclone, inverting the dependency direction #50/#51/#52 pinned. The rule is stated once in the ADR instead. A field declaredboolis 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 underdocs/uses correctly, because those files live indocs/— whileCONTEXT.mditself sits at the repository root, where noadr/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, becauseAGENTS.mdnamesCONTEXT.mdauthoritative: 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 namedocs/adr/…; the./adr/…form underdocs/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 andclone's WP-CLI quiet runner cited ADR-0015 with a../../-style path todocs/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, becauseSKILL.mdsends 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
readyselected no close-out case at all. Cases 2, 3 and 4 are allreadyjobs, told apart only by thefailure_phasethe role reported — and an absent verdict carries none, which is what makes it absent. Nor is that one route:failure_phaseis emitted by exactly one of the three roles that can enter the close-out, so a well-formedFAILEDfromdiscovery-classifyor fromextract-submitreached 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 singleGET /extractions/{id}) and from this machine's own state (what the run's scratchpad holds), with a reportedfailure_phasedemoted 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.partand is renamed to<scratchpad>/extract.containeronly 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.sqland<scratchpad>/extract-files/fixed for the same reason, and the bootstrap job's equivalents kept insidediscovery-classify's own per-run working directory, which both SKILLs' §3 had misplaced at the scratchpad root. Cases 2 and 4 became one row: areadyjob 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-consumedone, 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-testphase returnedFAILEDwhen 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, answered1for two opposite conditions: its report carries afail, 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.pynow answers with three exit codes —0clean,1the checks ran and the report holds at least onefail,2it 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:statusisFAILEDiff the script exits1. 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,DONEandFAILED, asextract-transferhas them — and an anomaly rides in the evidence block's existinganomalieslist beside aDONE, the same channel that already carriesskipped_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. Making1mean one thing also closed a second route to it: the report write and summary emit that follow the checks sat outside_main_verify'stry, so a full disk or an unwritable--logpath exited1on a clone every check had passed — the code that now fires the destructive close-out. Both are inside the guard and answer2instead, because a report nobody could write says nothing about the copy. See ADR-0028. - A
cancelthe Extractor refuses because the job's lock is held no longer ends a run on a cleanup error. #54 taught the consume to retry a409 kntnt_extractor_lockedand 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,queuedandrunningincluded, while the consume only ever reaches a job already goneready. 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: onetick_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: areadyjob races a tick only between that tick publishing the artifact and releasing its own lock, while aqueuedorrunningjob'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 terminalfailedjob 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 reachedready, 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.429is 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 --strictcleanliness 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 returningboolnarrows a type for no checker the way theisinstanceit replaced did — so a size the predicate had just vouched for reached the child tuple asAnywhere anintis declared. It is now read through a thin_boundary_intaccessor 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 aTypeIsannotation 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
- The canonical discovery document now carries the Extractor API version the health check observed, as a required top-level
api_versionsibling ofenvironment— plumbing that previously stopped at the health check itself, discarded the moment the comparison against the floor and ceiling was made.agents/discovery-classify.mdpasses 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 absentprogress.chunks_doneand widened stall window below API version 6, andstrictbeing 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.
- Two-phase discovery's "the bootstrap is small" claim is now written down as a premise that holds only where
wp_postmetais small, so a bloated table is a known case rather than a surprise. A skip-wp_postmetafallback 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 isKNTNT_EXTRACTOR_APP_PASSWORDin 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 theGET /fileswalk and thePOSTis skipped and reported rather than failing the whole job.extract-transfersends the member, surfaces anyskipped_filesthe 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 hard404; the errordatanow names every missing table and every missing file, so the recovery is no longer a full re-walk.strictdefaults 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 /environmentdefine-disclosure allow-list is the change that movedapi_version, and source inspection of the Extractor's own ADRs, its normativedocs/container-format.md, and its artifact-writing commit history sincev0.5.1found 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, andscripts/unseal.pyneeded no change. See ADR-0021.
- A define whose value the Extractor withheld (
nullon the wire fromGET /environment) is no longer ported.scripts/classify.pynow classifies such a define auto-excluded under a newwithheldclass instead of offering it at thewp_config_definesgate, soscripts/wpconfig_block.pynever writesdefine('NAME', null);into the localwp-config.php— a definephp -laccepts and the smoke test never catches, but which makesdefined('NAME')reporttrueand 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 anullvalue. Bothcloneandpullnow 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.pyalso gained its own rejection of aNonedefine 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.buildingwere selected for clone — and the last of those can vanish mid-run becausePOST /extractionsreclaims the directory the selection just named.ALWAYS_EXCLUDEDnow 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-defaultWP_CONTENT_DIR, anUPLOADSdefine) excludes them where they actually are and not only at the standard location, which is the layoutclassify.pyalready honours everywhere else. Glob-bearing directory prefixes match infilter_manifest.pyandbaseline_diff.pythe 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_donedoes 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.pynow widens the stall window to 40 minutes from the first poll that observeschunks_doneabsent, 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
FAILEDverdict 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 reachedready, consumes one that reachedreadyand 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
docs/poll-discipline.mdis 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 insidetests/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.
agents/extract-transfer.md's two poll hard rules are split apart and carry the canonical wording verbatim, matchingagents/discovery-classify.mdword for word rather than merely in substance.
0.8.0 – 2026-08-14
- The Extractor version pin gains a verified ceiling alongside its floor: the skills are checked against API version ≤ 6, and a
GET /statusreporting 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.pybinds the ceiling across every surface that states the pin, so raising it is a deliberate act of verification rather than an omission.
scripts/classify.pyno 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.serunsws-form-fluentcrm, which matched thews-formprefix 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, sows-form-mailchimp-pronames Mailchimp and a remainder merely ending in a service's letters does not.- Both
cloneandpulllist 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_doneas 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 reported3/186unchanged 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 addschunks_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.mdandagents/extract-transfer.mdstate 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-classifyreturned 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 aFAILEDcarrying 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-classifycreates 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 ownconsumereturned404because the orchestrator had already consumed the job — an evidence block is only evidence when nothing else could have written the files it describes.
- The container-format contract in
docs/implementation-notes.mdstated that slices concatenate to the whole-table dump byte for byte because the plugin cuts them on extended-INSERTboundaries. 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.pypins the uneven-statement shape that a byte budget produces.
0.7.0 – 2026-08-13
scripts/unseal.pyreads 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'smemory_limitormax_execution_timewas 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.
- 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 onlyrest_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_cbcache-buster (&-appended in the pagedGET /filescursor 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 credentialedGET /status'sauthenticated_asandcapabilitieson Extractor API version ≥ 4, and otherwise from a same-username/wrong-password probe whoseincorrect_passwordproves theAuthorizationheader arrives — before it interprets any refusal; the old inference from a403on/environmentbeside a200on/audit-logwas unsound, since a cached refusal and a wrong username both mimic it exactly. (3) An authenticated response carryingx-litespeed-cache: hit,cf-cache-status: HIT,x-cache: HIT,x-proxy-cache: HIT, or a non-zeroage:now raises a loud, specific diagnostic and aborts, instead of being read as a genuine answer.docs/implementation-notes.mdgains 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 WordPressuser_loginis frequently an email address in its own right —thomas@kntnt.com@safeteam.seis a real, valid account name — so a first-@split produces the loginthomas, which does not exist, and authenticates as nobody without reporting any error. The documentation now states explicitly that the first component is theuser_login(often an email), where an operator finds theirs (Users → Profile → Username), and that a credentialedGET /statusreports it back asauthenticated_as.
0.6.0 – 2026-07-24
- A new deterministic helper,
scripts/wpconfig_block.py, now owns thewp-config.phpmarked-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 plusDISABLE_WP_CRONintersected 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 repeateddefine()on the same constant fatals. Bothclone/pull§9.4 now invoke the helper and lint the result withddev exec php -l wp-config.phpinstead of describing the edit by hand. The helper fails loud at the boundary on the malformed-input classes that would emit a duplicatedefine()on one constant — a runtime fatalphp -lcannot catch: a name repeated within the input defines,DISABLE_WP_CRONsmuggled into the defines (its write is solely thecronfield'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, whichjson.loadsaccepts 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_submissionsgate and a sixth classification family.scripts/classify.pytags FluentCRM, MailPoet, The Newsletter Plugin, and Mailster subscriber/address stores (fc_subscribersand 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-matchednewslettertable plusnewsletter_sent*/newsletter_stats*/newsletter_user*) ascrm_subscribers, while the engine's definitions (campaigns, funnels, lists, tags, terms, meta, url stores, and Newsletter'snewsletter_emailscampaign bodies) carry in full as site config. Every engine inbootstrap_parse.py'sMAILER_ENGINESrecognition 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.pyadds thecrm_subscribersdecision and folds each privacy gate's resolved carry independently intodb_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).
- 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 withsecurity find-generic-password -s ... -a ... -winside a subshell, with theKNTNT_EXTRACTOR_APP_PASSWORDenvironment 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 acredentialreference — 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).
-
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 theheavy_blobs=excludedefault could not reach and would transfer silently — is now flagged too.scripts/discovery.pyadds arootand acontentbreakdown beside the existing uploads one, each summing its top-level subdirectories (loose files are not directories and do not count), andscripts/classify.pyruns 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 defaultwp-contentsegment is assumed rather than exposing the payload tree. All flags merge into the singleblobs.flaggedlist, so the existingheavy_blobsgate andscripts/build_exclusions.pykeep 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'ssealmode (the development/test round-trip aid) still reached into each segment with baresegment["name"]/segment["data"]indexing, so a malformed segment raised an uncaughtKeyErrortraceback instead of the cleanunseal.py: missing required config key: 'name'diagnostic the rest of the helper emits — a residue of #47, which deliberately narrowedmain()'sexceptto stop masking strayKeyErrors but left these two accesses unguarded. Both per-segment reads now go through the same_requiredaccessor, so a missing key fails loud and uniform like every other required-key check.sealis off theclone/pullhot 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.jsonone level up, in the operator's invocationcwd. That mismatch guaranteed two false smoke FAILs (check_saved_plan_present,check_baseline_present, both anchored at the site directory) and left a futurepullunable 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, sincemkwp(≥ 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, inCONTEXT.md, and referenced consistently fromdocs/spec.mdand both skills (#40). -
The poll discipline no longer treats a single spurious
404onGET /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 returned404twice mid-job while the job was alive and progressing, a server-side non-atomicjob.jsonrewrite race (Kntnt/kntnt-extractor#20). A404is now terminal only when confirmed vanished: the first404is treated as a transport-class fault, retried under the existing 30 s / 60 s backoff, and cross-checked againstGET /extractions; the job counts as vanished only when the re-poll also404s and the id is absent from the listing — otherwise the blip is logged and retried, and polling continues within budget. ADR-0018, bothclone/pullSKILLs,agents/extract-transfer.md, andagents/discovery-classify.md's compact reference now state the rule identically (#41, refines ADR-0018 / #34). -
Every documented
keygeninvocation was the bareuv run scripts/unseal.py keygen, but the helper always parses a JSON envelope from stdin andkeygenrequires a caller-chosenprivate_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 thecloneandpullSKILLs, plusagents/discovery-classify.mdandagents/extract-transfer.md— now pipe the real envelopeecho '{"private_key_path": "..."}' | uv run scripts/unseal.py keygen, andkeygenfed empty or malformed stdin now exits non-zero with a self-documentingunseal.py: keygen requires JSON on stdin: {"private_key_path": "..."}instead of a bare parse error. The stdin/stdout contract is now pinned and guarded bytests/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(the2is the response-bodyapi_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 shapescripts/resolve_plan.pyactually emits; theclone/pullSKILLs described their companion docs as living "alongside this file" when they sit at the repo root; andagents/discovery-classify.mdnamed thePOST /extractionstables in prose but never gave the request-body shape. All four are corrected, and a new guard test binds the saved-plan example toresolve_plan.pyso it cannot silently drift again (#45). -
scripts/discovery.pyreported WordPress's empty-directory guard filewp-content/themes/index.phpas a theme (the live smoke returnedthemes: ["index.php", "ollie"]), because_relative_childrentook 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 bothderive_themesandderive_uploads_subdirectoriesdrop 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'sunsealmode printed a raw quotedKeyError(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 clearmissing required config key: 'sql_path'diagnostic and exits non-zero, writing no.sqlfile;main()'s exception handling was narrowed so a strayKeyErroris no longer masked as a user error. Found on the second live smoke test of the Extractor cutover (#47). -
scripts/build_selection.pyrequired itsfilesinput as a flat list of path strings, butclone§5 feeds itscripts/filter_manifest.py's output — a{entries: [{path, size, mtime}], scope: {...}}object — so a literal implementation of the chained steps failed withfiles must be a list, got dictand forced an undocumented[.entries[].path]transform between them.build_selection.pynow 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.pyrequired itsdefinesinput as[{name, value}]objects, but the §9.4 pipeline feeds it the resolvedwp_config_definesdecision — whichscripts/resolve_plan.pydeliberately 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 withdefines[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 pipingclassifications.defines.portablewholesale to sidestep it would silently port the very defines the operator deselected at thewp_config_definesgate, defeating the gate. The writer now owns the join deterministically:definestakes the classifier's portable[{name, value}]records as the value source, an optionalselecttakes 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 absentselectwrites every record (unchanged for a caller that already filtered). Bothclone§9.4 andpull§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.pynow drives the real helpers end to end (#42). -
The
discovery-classifyphase left its unsealed bootstrap dump — 14 MB of cleartext user and subscriber rows — in the scratchpad afterscripts/bootstrap_parse.pyhad consumed it, alongside the sealed container; harmless under an ephemeral/private/tmpscratchpad but unmanaged PII at rest once the operator points the scratchpad at a persistent directory.bootstrap_parse.pynow deletes the unsealed dump, the sealed container, and the run's private key immediately after parsing — the local analogue of the Extractor's ownPOST /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
- 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 — andscripts/classify.pynow emitsuploads_prefixso 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).
ALWAYS_EXCLUDEDnow covers the credential-bearing pattern family that previously sailed through unfiltered: every backup, editor-swap, and variant sibling ofwp-config.php(wp-config.php.*,wp-config.php~,.wp-config.php.sw?,wp-config-*.php— withwp-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.pyandscripts/baseline_diff.py'sis_excludednow 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 livewp-config.php.bak-*files carrying the complete secret family in clear text sailed through the exclusion set and landed in local staging (#36).ALWAYS_EXCLUDEDnow 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.pyandscripts/baseline_diff.py'sis_excludednow also match a bare top-level directory prefix (no/of its own, likewp-admin) against itself and its descendants, not only against a root-anchored file pattern. The specification, theclone/pullskills, and the implementation notes no longer claimGET /filesreturns 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
CONTEXT.md's Exposure window glossary entry named the wrong endpoint for the happy-path close: consuming a finished job isPOST /extractions/{id}/consume, notDELETE /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
failedstate, 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
- The control channel is now the Kntnt Extractor plugin's REST API instead of the Novamira MCP
execute-phpchannel.cloneandpullreach production over HTTPS as a real WordPress user authenticated with an Application Password (HTTP basic auth), and every data call is gated on both thekntnt_extractor_operateandmanage_optionscapabilities. 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 pagedGET /filessupply 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-sideexecute-phpdiscovery 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
mkwpskill now recommends installing Kntnt Extractor by default on a freshly scaffolded site — resolvingkntnt-extractor-*.zipfrom the plugin's latest GitHub release, with a graceful drop on failure — so the new site is already reachable by a laterclone/pull. - The required CLI tool floor is now
uv,jq, andcurl.shasum/sha256sumandopensslare no longer needed: integrity is authenticated encryption verified when the container unseals, and the unseal is auv-run helper withpynaclas an inline dependency.
- The Novamira MCP control channel and its
execute-php/run-wp-cliabilities, the SSH path, and the entire client-side pack machinery — the generatedpack.sh, the server-side passphrase, theopensslencryption, 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
- Documentation reconciled with the code across the specification, the manual pages, and
templates/README.md, with no change to behaviour:templates/README.mdnow documentskntnt-wp-skills-mailpit.phpas the local capture mu-plugin the engine drops into the copy — not anexecute-phpcontrol-channel payload — and scopes the "nodeclare(strict_types=1), no namespace" convention to the payload table so it no longer contradicts that file;docs/spec.mdlists the fifthbuild-ollie-sitehelper (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-derivedclassify.pyconstants, never persisted); theclone/pullmanual pages describe the--include-media/--exclude-mediadefault as the built-in constant it is rather than a discovery-derived one;README.mdnotes the production-side pack script is generated at runtime byscripts/pack_script.pyrather than shipped as a static shell template; anddocs/implementation-notes.mdcorrects the Mailpit host to127.0.0.1:1025and adds@@collation_databaseto the discovery queries. Thebuild-ollie-sitereference and helper docstrings are likewise corrected (M<n>module tags label sections rather than detect components; thecheck_contrast.pyanddump_ground_truth.pyusage strings).
0.3.1 – 2026-07-20
- 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'scheck_entity_countsnow derives its live counts with raw SQL overddev wp db query— the same unfilteredCOUNT(*)templates/discovery.phpbuilds the expectation from, mirrored clause-for-clause per entity (published posts, published pages, attachments withtrash/auto-draftexcluded, users) against the site's real table prefix — rather than throughwp post list/wp user list, which go viaWP_Queryand 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 byscripts/discovery.py'sdetect_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
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'stheme.json, component patterns (Inserter: nomolecules), section patterns (full-width bands composing components bywp:patternreference 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-siteunderskills/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 danglingwp:patternreferences, with alint:allowpragma 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), andinstantiate_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 livedo_blocks()render check that works forInserter: nopatterns). - Being gate-driven and human-in-the-loop,
build-ollie-sitecarries no operational flags — only the help gate — and itsscripts/flags.pyregistry entry, its manual page, and the help/docs consistency test's per-skill binding reflect that.
0.2.0 – 2026-07-20
- Plugin help: the
/kntnt-wp-skills:helpcommand and a full manual page for each skill (clone,pull) and for thehelpcommand itself, so/kntnt-wp-skills:help helpdocuments the reader (without listinghelpamong 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
ddevand its container backend, the required CLI tools (uv,jq,curl,shasum/sha256sum,openssl),mkwpforclone(via the sharedscripts/mkwp_guard.pyguard), and the target site's connected Novamira server with its fulldiscover-abilitiesinventory, 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--generatemode 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);
--yesruns 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-mailpin 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 themkwpcommand 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 laterclone/pull(ADR-0001), and never gathers or echoes a password — that staysmkwp's own random generation. A shared version guard (scripts/mkwp_guard.py) verifies the localmkwponPATHis at least 1.8.1 — the release that fixes Kntnt/mkwp#3, where an earliermkwpbroke the scaffold outright whenever--dirnamediverged from the site's name — before scaffolding.- The flag registry (
scripts/flags.py) now keys its surface per skill instead of one flattened set, sincemkwp's flags have nothing to do withclone/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
- Initial release.