cuioss

finalize-step-plugin-doctor

Finalize-phase wrapper that runs the plugin-doctor quality-gate — scoped to the skills the plan touched, or whole-tree when a plugin-doctor/plan-doctor rule changes or the scope read is indeterminate — gating structural lint before push

cuioss 5 Updated 2mo ago
GitHub

Install

npx skillscat add cuioss/plan-marshall/finalize-step-plugin-doctor

Install via the SkillsCat registry.

SKILL.md

Finalize Step: plugin-doctor

Purpose

Run the plugin-doctor quality-gate invariant rule set (argparse safety, argument-naming, manage-invocation, extension contracts, shell-substitution, lesson-id / historical prose, role-field) scoped to the marketplace source of any skill the plan modifies — gating structural lint before push. Catches structural breakage that the Python quality-gate (ruff/mypy/pytest) cannot detect. The affected_files read (Step 1) and skill-dir extraction (Step 2) SUPPLY the --paths targets the gate scopes to; the skip-clean exit (Step 3) skips the gate when the read succeeds and the plan touched no skill.

The wrapper runs in one of three mutually-exclusive modes, selected deterministically (see Step 2.5 for the precedence): it runs whole-tree (no --paths) when the changed set touches a plugin-doctor / plan-doctor analyzer or rule script — a rule change re-classifies skills the diff never touched, so only a full marketplace pass catches the breakage (the F1 trigger); it runs whole-tree when the affected_files read is indeterminate (broken, not empty); and otherwise it scopes the gate to the changed skill directories (the common case). In scoped mode the wrapper additionally emits a loud cross-skill divergence WARNING, because a scoped --paths run structurally cannot evaluate plugin-doctor's cross-skill rules — those whose verdict spans more than the touched skill and whose finding anchors to a counterpart file OUTSIDE the touched paths (the PR #915 scoped-green / whole-tree-red class). The warning keeps the common-case scoped run for skill-local rules unchanged while surfacing, at finalize, that the cross-skill rule class was not gated — rather than silently passing as if it had been.

Ordered at order: 6 so it slots between default:finalize-step-pre-push-quality-gate (order 5) and default:pre-submission-self-review (order 7) — structural lint gates before the commit is pushed, not after CI.

When the plan runs in an isolated worktree, the gate first regenerates a worktree-bound executor so the manage-invocation-invalid rule probes each script's --help against the worktree's TRUE argparse surface. Without this step, the worktree's .plan/execute-script.py is a symlink to the main checkout's executor, whose embedded mappings resolve every manage-* notation to the main-checkout (pre-plan) script — making a newly added subcommand read as a false-positive "unregistered" and a newly required flag read as a false-negative that masks the real CI finding.

Interface Contract

Invoked by plan-marshall:phase-6-finalize for projects that include project:finalize-step-plugin-doctor in their phase-6-finalize.steps list.

Accepts the standard finalize-step arguments:

  • --plan-id — plan identifier (required, used to query references.json for affected_files)
  • --iteration — finalize iteration counter (accepted for contract compliance, no effect)

MUST be ordered before default:commit-push in the steps list so structural lint gates before push.

In a worktree-backed plan, the gate step is preceded by a worktree-fresh-executor regeneration (Step 4 below) that rebinds notation→path resolution to the worktree's scripts. Regeneration failure is non-fatal (logged WARN) — a gate run against the still-stale executor is no worse than not regenerating, so finalize must not hard-block on a mapping refresh.

HEAD-dependency

This step declares head_dependent: true in its frontmatter — that fact IS the membership declaration the dispatcher's re-entry check reads (see ext-point-finalize-step.md § "Implementor Frontmatter"). Its verdict is a structural-lint pass/fail gate over the tree's marketplace source, so a HEAD advance changes both the derived --paths scope (Step 2) and the gate's verdict. Both --outcome done records below assert something about the tree that a loop-back commit can falsify — the Step 5 pass asserts "the gated source is lint-clean", and the Step 3 Case (a) skip-clean asserts "the plan touched no skill" — so BOTH MUST capture the worktree HEAD immediately before their mark-step-done call and forward it via --head-at-completion {sha}. The --outcome failed record does NOT take the flag: the dispatcher retries failed records on re-entry regardless of HEAD, so the SHA carries no decision value there.

Workflow

Step 1: Read affected files

python3 .plan/execute-script.py plan-marshall:manage-references:manage-references get \
  --plan-id {plan_id} --field affected_files

Parse the returned list of file paths. Capture the read's status and error: status: success means the read resolved (the list MAY be empty); status: error with error: field_not_found means the scope-deriving read is broken (not empty) and Step 3's indeterminate branch applies.

Step 2: Extract skill directory paths (the --paths supplier)

Filter the file list to entries matching either pattern:

  • marketplace/bundles/{bundle}/skills/{skill}/ (marketplace skills)
  • .claude/skills/{skill}/ (project-local skills)

For each matching file, extract the skill directory path (everything up to and including the skill name directory). Deduplicate the result. These extracted skill directories are the --paths targets supplied to the quality-gate invocation in Step 5.

Example: marketplace/bundles/plan-marshall/skills/phase-5-execute/SKILL.md → marketplace/bundles/plan-marshall/skills/phase-5-execute

Step 2.5: Select gate mode (F1 trigger → whole-tree)

The gate runs in exactly one of three modes. Evaluate them in this fixed precedence order and pick the first that applies:

  1. F1-trigger whole-tree mode — at least one entry in the Step 1 affected_files list matches either marketplace/bundles/pm-plugin-development/skills/plugin-doctor/** OR marketplace/bundles/plan-marshall/skills/plan-doctor/** (a plugin-doctor / plan-doctor analyzer or rule script changed). A rule change re-classifies skills the diff never touched, so the gate MUST run over the whole marketplace — Step 5 runs quality-gate with no --paths against the resolved --marketplace-root. This precedes the skip-clean exit: even when no skill directory survived Step 2 filtering, an F1-trigger hit forces a whole-tree run rather than a skip.
  2. Indeterminate-read whole-tree fallback — the Step 1 read returned status: error / error: field_not_found (the scope-deriving read is broken, not empty). Step 5 runs quality-gate with no --paths (see Step 3 Case (b)).
  3. Scoped mode (the common case) — neither whole-tree condition holds; Step 5 scopes the gate to the skill directories extracted in Step 2 and emits the loud cross-skill divergence WARNING described below.

When the F1 trigger fires, skip the Step 3 skip-clean exit entirely and proceed to Step 4, then run the whole-tree invocation in Step 5. The two whole-tree modes (F1 trigger, indeterminate read) share the same no---paths invocation — they differ only in what selects them.

Cross-skill divergence gap (why scoped mode warns). doctor-marketplace.py quality-gate --paths filters every file-anchored finding to those whose file resolves under a supplied path (_finding_in_scope). A cross-skill rule — one whose verdict spans more than the touched skill and whose finding anchors to a counterpart file OUTSIDE --paths — is therefore structurally invisible to a scoped run: the scoped run sees only the touched side and passes, while a whole-tree run sees both sides and fails. This is the PR #915 divergence class: a non-rule skill edit changed one side of a cross-skill invariant whose counterpart lived in an untouched skill. Concrete quality-gate rules in this class include manage-invocation-invalid / missing-canonical-block (a referenced script or its canonical block in an untouched skill), provides-method-table-drift, literal-count-drift, resolver-matrix-coverage, and the source-of-truth-duplication / count-prose cross-reference rules. validate_extension_contracts is the SOLE exception — it always runs whole-tree unfiltered even under --paths, so it is NOT part of the gap.

The bounded correction (per the request's caught-or-loudly-warned latitude, adding no new whole-tree mechanism and NOT making the step unconditionally whole-tree): scoped mode MUST emit a loud finalize WARNING in Step 5 recording that scoped mode did not evaluate the cross-skill rule class. The warning fires in scoped mode regardless of whether the scoped gate passes or fails — it documents the rules that were NOT run, not the scoped result. A scoped run therefore never silently passes as if plugin-doctor's cross-skill rules had been gated; the #915 class is surfaced at finalize rather than first at whole-tree CI.

Step 3: Skip-clean exit (only on a successful, genuinely-empty read)

The skip-clean exit is taken ONLY when the Step 2.5 F1 trigger did NOT fire AND the Step 1 read succeeded (status: success) AND zero skill paths remain after Step 2 filtering — the plan genuinely touched no skill. An F1-trigger hit (Step 2.5 mode 1) forces a whole-tree run and MUST NOT take this exit. An errored (status: error / error: field_not_found) read is indeterminate (the input is broken, not empty) and MUST NOT take this exit either; it falls through to the whole-tree fallback below.

Case (a) — successful read, zero skill paths after filtering (the plan touched no skill): log, record the step as done, and return success:

python3 .plan/execute-script.py plan-marshall:manage-logging:manage-logging \
  work --plan-id {plan_id} --level INFO \
  --message "[STATUS] (project:finalize-step-plugin-doctor) No skill changes detected. Skipping plugin-doctor quality-gate"

This branch precedes Step 4, so resolve the worktree path here before capturing HEAD:

python3 .plan/execute-script.py plan-marshall:manage-status:manage-status get-worktree-path \
  --plan-id {plan_id}

Capture worktree_path from the returned TOON (substitute . when it is empty — the main-checkout flow), then resolve the HEAD SHA immediately before marking done, per § HEAD-dependency:

git -C {worktree_path} rev-parse HEAD

Capture stdout as {sha} and forward it via --head-at-completion:

python3 .plan/execute-script.py plan-marshall:manage-status:manage-status mark-step-done \
  --plan-id {plan_id} --phase 6-finalize --step project:finalize-step-plugin-doctor --outcome done \
  --display-detail "no skill changes detected" \
  --head-at-completion {sha}

Case (b) — read returns status: error / error: field_not_found (indeterminate scope, not empty): do NOT take the skip-clean exit and do NOT record --outcome done --display-detail "no skill changes detected" off the broken read. Instead, fall back to gating the full plan scope so structural lint still runs: log the indeterminate-read fallback, proceed to Step 4 (resolve worktree path), then in Step 5 run the quality-gate with no --paths scoping (whole-tree gate) against the resolved --marketplace-root:

python3 .plan/execute-script.py plan-marshall:manage-logging:manage-logging \
  work --plan-id {plan_id} --level WARNING \
  --message "[STATUS] (project:finalize-step-plugin-doctor) affected_files read indeterminate. Falling back to whole-tree plugin-doctor quality-gate"

Step 4: Regenerate a worktree-fresh executor

Resolve the active worktree path:

python3 .plan/execute-script.py plan-marshall:manage-status:manage-status get-worktree-path \
  --plan-id {plan_id}

Parse worktree_path from the returned TOON. This value also determines the --marketplace-root passed to Step 5:

  • Non-empty worktree_path (worktree-backed plan) — Step 5 uses --marketplace-root {worktree_path}/marketplace (the parent of bundles/ inside the worktree, NOT bundles/), so the gate runs against the in-progress edits.
  • Empty worktree_path (main-checkout flow) — Step 5 uses --marketplace-root marketplace. Skip the executor regeneration below and proceed to Step 5.

When worktree_path is non-empty, replace the worktree's .plan/execute-script.py symlink (which points at the main-checkout executor) with a worktree-bound executor so the manage-invocation-invalid rule probes --help against the worktree's argparse:

python3 .plan/execute-script.py plan-marshall:tools-script-executor:generate_executor generate \
  --marketplace-root {worktree_path}

This mirrors test/conftest.py::_ensure_executor_present on CI. Regeneration failure is non-fatal: log a WARN line and proceed to Step 5 with the existing executor.

python3 .plan/execute-script.py plan-marshall:manage-logging:manage-logging \
  work --plan-id {plan_id} --level WARNING \
  --message "[STATUS] (project:finalize-step-plugin-doctor) Worktree executor regeneration failed. Gating against existing executor"

Step 5: Run the quality-gate

Scoped invocation (the common case — Step 1 read succeeded and Step 2 yielded one or more skill directories): run the plugin-doctor quality-gate scoped to the skill directories extracted in Step 2, against the marketplace root resolved in Step 4 ({worktree_path}/marketplace for a worktree, marketplace for the main checkout).

Cross-skill divergence WARNING (scoped mode only) — emit BEFORE the scoped gate. Because the scoped run cannot evaluate plugin-doctor's cross-skill rules (Step 2.5, "Cross-skill divergence gap"), emit a loud finalize WARNING recording that scoped mode did not gate the cross-skill rule class. Emit it in scoped mode ONLY (never for either whole-tree mode, which already covers cross-skill rules), and emit it before running the scoped gate below so it fires regardless of the scoped gate's pass/fail outcome — a failing scoped gate records failed and aborts finalize (see below), so a warning placed after the invocation would never fire on a red gate. It names the rules that were NOT run, so a #915-class scoped-green / whole-tree-red divergence is surfaced at finalize rather than first at CI. {N} is the count of scoped skill directories:

python3 .plan/execute-script.py plan-marshall:manage-logging:manage-logging \
  work --plan-id {plan_id} --level WARNING \
  --message "[STATUS] (project:finalize-step-plugin-doctor) scoped plugin-doctor cannot detect cross-skill divergence — scoped mode gated skill-local rules over {N} skill dir(s) only. Cross-skill rules whose counterpart lives outside --paths were NOT evaluated. A cross-skill invariant broken by this change would surface first at whole-tree CI (PR #915 class)."

After the WARNING is emitted, run the scoped gate:

python3 .plan/execute-script.py pm-plugin-development:plugin-doctor:doctor-marketplace \
  quality-gate --paths {space-separated skill directory paths} --marketplace-root {marketplace root}

Whole-tree invocation (either whole-tree mode from Step 2.5 — the F1 trigger fired, OR the indeterminate case of Step 3 Case (b) where the affected_files read errored / field_not_found): run the quality-gate with no --paths scoping against the same resolved marketplace root, so the structural lint runs over the whole tree — catching a doctor / plan-doctor rule change that breaks an otherwise-untouched skill (F1), and not false-skipping off a broken scope-deriving read (indeterminate):

python3 .plan/execute-script.py pm-plugin-development:plugin-doctor:doctor-marketplace \
  quality-gate --marketplace-root {marketplace root}

Parse the TOON output. The violation signal is status: fail (the script also exits 1) OR total_issues > 0. On a violation, log the failure, record the step outcome failed, and exit with status: error so phase-6-finalize aborts before default:commit-push:

python3 .plan/execute-script.py plan-marshall:manage-status:manage-status mark-step-done \
  --plan-id {plan_id} --phase 6-finalize --step project:finalize-step-plugin-doctor --outcome failed \
  --display-detail "plugin-doctor: {total_issues} violations"

On status: pass / total_issues: 0, log, record the step as done, and exit success. Resolve the HEAD SHA immediately before marking done, per § HEAD-dependency ({worktree_path} is the value resolved in Step 4; substitute . when it is empty):

git -C {worktree_path} rev-parse HEAD

Capture stdout as {sha} and forward it via --head-at-completion:

python3 .plan/execute-script.py plan-marshall:manage-status:manage-status mark-step-done \
  --plan-id {plan_id} --phase 6-finalize --step project:finalize-step-plugin-doctor --outcome done \
  --display-detail "plugin-doctor clean: {N} skills gated" \
  --head-at-completion {sha}

Error Handling

Scenario Action
Missing pm-plugin-development bundle Fatal config error — the project opted into the wrapper without the dependency
Empty worktree_path (main-checkout flow) Skip Step 4 regeneration — the executor already reflects the current checkout; proceed to the scan
Worktree executor regeneration fails Non-fatal — log WARN and gate against the existing executor; finalize does not hard-block on a mapping refresh
Changed set touches a plugin-doctor / plan-doctor analyzer or rule script (F1 trigger, Step 2.5 mode 1) Whole-tree mode: do NOT skip-clean even when no skill dir survived Step 2; run the whole-tree quality-gate (Step 5, no --paths) so a rule change that breaks an untouched skill is caught, then record the outcome from that gate run
Scoped mode selected (Step 2.5 mode 3 — the common case) Emit the loud cross-skill divergence WARNING ([STATUS] ... scoped plugin-doctor cannot detect cross-skill divergence) recording that cross-skill rules whose counterpart lives outside --paths were NOT evaluated — fired regardless of the scoped gate's pass/fail — then run the scoped quality-gate and record its outcome. Scoped mode never silently passes as if cross-skill rules were gated; this surfaces the PR #915 divergence class at finalize instead of first at CI
affected_files read succeeds, zero skill paths after filtering, F1 trigger did NOT fire Skip-clean exit (plan touched no skills) — record mark-step-done --outcome done --display-detail "no skill changes detected" --head-at-completion {sha} so the phase_steps_complete handshake invariant counts the step as done and a later HEAD advance re-fires it
affected_files read returns status: error / error: field_not_found Indeterminate: do NOT skip-clean; fall back to the whole-tree quality-gate (Step 5, no --paths) so structural lint still runs, then record the outcome from that gate run
plugin-doctor status: fail / total_issues > 0 Fatal — record mark-step-done --outcome failed --display-detail "plugin-doctor: {total_issues} violations", then abort finalize before default:commit-push
plugin-doctor status: pass / total_issues: 0 Record mark-step-done --outcome done --display-detail "plugin-doctor clean: {N} skills gated" --head-at-completion {sha}

Related