Releases: Kntnt/kntnt-wp-skills
Release list
v0.10.0
The client half of a coordinated release with Kntnt Extractor 0.6.0. The verified Extractor API-version ceiling rises from 6 to 7, which is the act of declaring this client correct against that build — so install the Extractor on production first, then this, and do neither while an extraction is in flight.
Three things carry the release. A define whose value the Extractor withholds is no longer ported: null on the wire is a masking value, and writing define('NAME', null) locally makes defined('NAME') true and silently suppresses whatever fallback the owning plugin runs for "not configured". This was latent until the Extractor replaced its secret deny-list with an allow-list, at which point a masked third-party key would have reached the writer. The poll loop moved out of prose and into scripts/poll_extraction.py — one blocking invocation, one terminal verdict — instead of being rewritten by the agent from eight pinned literals on every run. And the main extraction lost its overall wall-clock budget: a 3600 s cap expired on a healthy, visibly-advancing run that then had to be re-polled by hand, and the stall window is the check that actually distinguishes dead from slow.
The run report is also more honest about what it cannot do: it now states the Extractor API version production reported and, when that is below this client's ceiling, names each behaviour degraded on that host — three at the time of writing. The remedy for all of them is upgrading production's Extractor, never a client-side workaround.
A limit worth stating: raising the ceiling to 7 was verified by source inspection from three angles, not by unsealing a version-7 container. No such container existed when the verification was done. The record of exactly what was and was not checked is in plans/004-raise-the-verified-api-version-ceiling-to-7.md, now tracked in the repository rather than living only on the maintainer's machine.
Added
- 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.
Changed
- 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.
Fixed
- 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 ...
v0.9.0
The poll discipline now has one place where its wording is decided. It used to be pinned inside a test file — a poor home for a product decision — and the rules stated only in prose drifted apart, as two agents proved by coming to state the same ban in different words.
Added
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.
Changed
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.
Full changelog: https://github.com/Kntnt/kntnt-wp-skills/blob/v0.9.0/CHANGELOG.md
v0.8.0
Two silent failures, one loud one, and the tripwire that would have caught all three. A form add-on the service registry did not know dropped a mandatory risk warning without a word; the contract between this client and the Extractor's artifact had no test binding it; and a poll agent that could not sit still returned three times without a verdict.
Added
- 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.
Changed
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.
Fixed
- 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.
Full changelog: https://github.com/Kntnt/kntnt-wp-skills/blob/v0.8.0/CHANGELOG.md
v0.7.0
Added
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.
Fixed
- The control channel no longer mistakes a failed authentication, or a cached error response, for a missing capability. Three changes, one root cause: an authentication attempt with a wrong username is treated as anonymous by WordPress —
wp_authenticate_application_password()short-circuits silently on an unknown login and returns no error of its own — so the caller sees 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.
v0.6.0
Hardening from the second live smoke test: a heavy-blob heuristic that now sees stray directories anywhere in the install, a default-empty privacy gate for CRM/mass-mailer subscriber tables, and a deterministic wp-config.php marked-block writer — plus a batch of contract and documentation fixes across the transfer engine.
Added
- 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).
Changed
- The Application Password's home is now a documented convention instead of living only in the operator's head: macOS Keychain, service
kntnt-extractor-app-password, account<wp-user>@<host>, retrieved 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).
Fixed
-
The heavy-blob heuristic is no longer uploads-scoped: a heavy stray directory anywhere else — the 7.6 GB install-root
2026/directory the second live smoke test found, which 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-documenting...
v0.5.0
Added
- A single deterministic assembler,
scripts/build_exclusions.py, now builds the resolved exclusion set — the union of a canonical always-excluded constant (ALWAYS_EXCLUDED: the configuration file, the WordPress drop-ins, the debug log, the cache dir, and the upgrade dirs) with the decision-gated DB-known thumbnails, flagged heavy blobs, and — under--exclude-media— the whole uploads tree. Both consumers that previously hand-assembled the set from the same ingredients now obtain it from this one helper: the extraction file selection (clone §5) and the baseline manifest (clone §9.12, pull's diff), so the two are byte-identical and a scope change can never poison the pull deletion diff. The always-excluded paths exist exactly once, in the constant — every prose reference points at it rather than re-enumerating — 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).
Fixed
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).
Full changelog: https://github.com/Kntnt/kntnt-wp-skills/blob/v0.5.0/CHANGELOG.md
v0.4.1
Fixed
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).
Full changelog: https://github.com/Kntnt/kntnt-wp-skills/blob/v0.4.1/CHANGELOG.md
v0.4.0
This release cuts the control channel over from the Novamira MCP to the Kntnt Extractor REST API (ADR-0016, ADR-0017). Breaking: production now requires the Kntnt Extractor plugin (REST API version ≥ 2) and an Application Password in place of the Novamira MCP server.
Changed
- 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.
Removed
- 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).
Full changelog: https://github.com/Kntnt/kntnt-wp-skills/blob/v0.4.0/CHANGELOG.md
v0.3.2
A documentation-only patch: the docs, manual pages, and helper docstrings are reconciled with the code. No plugin behaviour changed.
Fixed
- 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).
Full changelog: https://github.com/Kntnt/kntnt-wp-skills/blob/v0.3.2/CHANGELOG.md
v0.3.1
Fixed
- The entity-count smoke-test check no longer produces a false-positive FAIL on a site whose active plugin filters the main query.
scripts/smoke_test.py'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).
Full changelog: https://github.com/Kntnt/kntnt-wp-skills/blob/v0.3.1/CHANGELOG.md