Imported from CodySwannGT/lisa (
plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md). Install upstream withnpx skills add CodySwannGT/lisa --skill lisa-doctor. Copyright stays with the author.
Doctor: $ARGUMENTS
Run a read-only Lisa readiness audit for the current repository.
Purpose
/lisa:doctor is the deterministic answer to "is this repo actually ready to use Lisa?" It audits
the repository in grouped sections, reports each check as PASS, WARN, FAIL, or SKIP, and
emits one overall verdict: READY, READY_WITH_WARNINGS, or NOT_READY.
The command is repository-scoped. It validates only what can be observed from the current repo, current machine, and current runtime. It does not create automations, labels, tracker items, or other external state as part of the default audit path.
Inputs
- Optional flags in
$ARGUMENTSthat narrow or tune read-only validation. - The current repository root and its Lisa config files (
.lisa.config.json,.lisa.config.local.json) when present.
Confirmation policy
Do not ask whether to proceed. Once invoked, run the read-only audit, print the grouped results, emit the overall verdict, and stop.
Specifically forbidden:
- Previewing the number of checks and asking whether to continue.
- Offering "run a few checks first" or "dry-run vs real run" choices. This skill is already the read-only path.
- Performing setup mutations just because a failing check discovered something missing.
The only legitimate reasons to stop early are:
- The current working directory cannot be resolved to a repository/root the audit can inspect.
- The runtime blocks all required local reads needed to even classify the repo.
Audit contract
Doctor reports grouped checks in a stable, human-readable structure. The grouped sections include, as applicable to the current repo:
- Project detection and runtime basics — detect the project root, package/runtime surface, and whether Lisa is installed where the repo expects it.
- Lisa config readiness — read
.lisa.config.jsonand.lisa.config.local.jsonusing the same local-overrides-global semantics defined byconfig-resolution; report missing required keys, incompatible combinations, and committed-vs-local locality problems as findings rather than mutating config. - Tracker/source preflight — perform read-only readiness checks for the configured
trackerandsourceonly. If a required CLI, MCP surface, or auth context is unavailable in the current runtime, report that explicitly instead of pretending the repo is ready. - Runtime distribution surfaces — confirm the command, skill, hook, and related distribution
surfaces relevant to this repo are present where Lisa expects them on the active runtime, and
that the Lisa-owned enforcement artifacts (
scripts/lisa-hooks/*,scripts/lisa-enforcement-fallback.sh) match the installed Lisa version rather than an older copy. Present-but-stale is a finding: a guard whose fix never landed enforces the old behavior. - Automation readiness — inspect whether the configured queue source/tracker and scheduling prerequisites are observable, but do not create, edit, or delete automations during doctor.
- Optional GitHub Project coordination — when
github.projects.v2is configured, delegate the shared validation read togithub-project-v2instead of reimplementing ad-hoc GraphQL checks. Honor therequired=falsevsrequired=truesemantics documented byconfig-resolution: best-effort failures areWARN, required-mode failures areFAIL. - Optional wiki delegation — when a repo-local
wiki/exists, either summarize the specializedlisa-wiki-doctorverdict or explicitly report that deeper wiki checks are available there. The base doctor stays narrower than full wiki migration enforcement. - Optional Kane browser provider — when
verification.browser.kane.enabledis true, run the Lisa-owned readiness probe. Require explicit cloud-upload approval, the contract-tested exact version, a non-production environment allow-list, local Chrome/Chromium, Test Manager project/folder targeting,whoamiauthentication, and nonzero/observable credit readiness. Report missing executable/browser, auth failure, wrong target, zero credits, and control-plane failure separately. If Kane is not enabled, report an informationalOK/not enabled rather than probing or installing it.
If a check family is not applicable to the current repo, report SKIP with the reason.
Minimum config-readiness checks
The Lisa config group is not just "does a file exist?" Doctor must audit the config contract in this order:
-
Presence + parseability
FAILwhen.lisa.config.jsonis missing, empty, or invalid JSON.- Read
.lisa.config.local.jsononly when present; if present but invalid JSON,FAIL.
-
Merged effective config
- Resolve every key with the same per-key local-overrides-global semantics documented by
config-resolution. Doctor must describe findings against the effective merged value, not by pretending one file fully replaces the other.
- Resolve every key with the same per-key local-overrides-global semantics documented by
-
Required top-level dispatch keys
FAILwhen mergedtrackeris missing or is not one ofjira,github, orlinear.FAILwhen mergedsourceis present but is not one ofnotion,confluence,linear,github, orjira.
-
Vendor required-key audit
FAILwhen the configured tracker/source points at a vendor whose required keys are absent after merge. Examples:tracker=githubrequiresgithub.org+github.repo;tracker=jirarequiresatlassian.cloudId+jira.project;source=notionrequiresnotion.workspaceId+notion.prdDatabaseId.- Reuse the
config-resolutionvendor tables rather than inventing a second required-key list.
-
Local-vs-committed locality audit
WARNwhen developer-specific fields appear in committed config. At minimum enforce the documented local-only examples:atlassian.email,intake.assignee, andjira.verified_workflow_hash.WARNwhen project-wide shared fields exist only in.lisa.config.local.jsonand are absent from.lisa.config.json, because the current machine may work while the repository remains under-configured for teammates and automations. Examples includetracker,source,github.org,github.repo,atlassian.cloudId,atlassian.site,jira.project,linear.workspace,linear.teamKey, anddeploy.branches.
-
Deploy env-order audit (only when
deploy.branchesis present)PASS(or skip) whendeploy.branchesdefines a single environment —deploy.orderis optional and the back-sync chain is empty.PASS(or skip) whendeploy.branchesdefines multiple environments that all map to the same branch (e.g.dev/staging/productionall →main). The branches resolve to a single distinct branch, so there is nothing to back-sync, the chain is the empty no-op, anddeploy.orderis not required. Do not WARN.WARNwhendeploy.branchesresolves to more than one distinct branch butdeploy.orderis absent. Config-driven back-sync cannot derive a source→target chain without the env ranking; the repo must adddeploy.order(low→high, e.g.["dev","staging","production"]).FAILwhendeploy.orderis present but its env-name set does not exactly match the keys ofdeploy.branches(every env in one must appear in the other). A mismatch silently breaks the derived chain.- Reuse the
deploy.order/deploy.branchescontract fromconfig-resolution("Env order (sync-down chain)") rather than re-deriving the rules here.
Locality findings are advisory unless the merged config is unusable. Missing shared keys after the
merge are FAIL; shared keys that exist only locally are WARN.
Minimum tracker/source preflight checks
After config readiness passes far enough to resolve the merged tracker and optional source,
doctor must perform read-only preflight checks for the configured vendors only. It does not probe
every vendor Lisa supports.
- Scope the audit to configured vendors
- Audit the merged
tracker. - Audit the merged
sourceonly when present and distinct from the tracker. - Report every non-configured vendor as
SKIPrather than pretending it was checked.
- Audit the merged
- Prove a readable substrate exists
tracker=githuborsource=github: requireghCLI availability, a passinggh auth status, and a read probe against the configured repo such asgh repo view <org>/<repo>.tracker=jira,source=jira, orsource=confluence: follow theatlassian-accesssubstrate ladder and prove at least one read-capable path can see the configuredatlassian.cloudIdand vendor scope. Acceptable substrates areacli, Atlassian MCP, or the validated API-token/curl path documented byconfig-resolution.tracker=linearorsource=linear: require either readable Linear MCP access or a valid personal API-key probe against the configured workspace. When Linear is the tracker, doctor must also prove the configuredlinear.teamKeyis visible.source=notion: require either a Notion MCP identity match fornotion.workspaceIdor a valid internal-integration token probe, plus read visibility tonotion.prdDatabaseId.
- Separate missing tooling from missing auth or scope
- Missing executable / MCP substrate availability is a distinct observed fact, not the same as "auth failed."
- When a probe runs and fails, preserve the exact read-only failure text or HTTP/GraphQL status in the observed output so the operator can distinguish wrong workspace/site/repo from missing credentials.
- Severity ladder
PASSwhen at least one supported read-only substrate proves the configured vendor is reachable with the required scope.WARNwhen the configured vendor is reachable, but an additional optional substrate is unavailable and later Lisa flows would need to fall back.FAILwhen no supported substrate can prove read access for the configured tracker/source, or when the configured vendor target is unreadable from the current runtime.
Minimum GitHub Project coordination checks
When github.projects.v2 is configured, doctor must run one additional read-only coordination
check instead of treating the config block as implicitly ready.
- Delegate through the shared chokepoint
-
Call
lisa-github-project-v2in read-only resolution mode:operation: resolve-project -
Do not inline ad-hoc Project GraphQL in doctor. Setup, doctor, writers, and linked-PR flows must all read the same owner/access contract from the shared utility.
-
- Preserve exact namespace + access failures
-
Enforce the v1 namespace rule exactly as documented by the shared utility. If
github.projects.v2.owner.slugdoes not matchgithub.org, report:code: project_namespace_mismatch message: "github.projects.v2.owner.slug must match github.org in v1" remediation: "Use a Project owned by <github.org> or remove github.projects.v2." -
For owner-access or GraphQL failures, preserve the exact GitHub / GraphQL failure text in the observed output. Examples include missing Project,
Resource not accessible by integration, unsupported owner kind, or a wrong owner/number pair.
-
- Report exact remediation paths
- Doctor must make the next operator action explicit. At minimum, say whether they need to:
- choose a Project owned by the tracked repo namespace,
- grant the token Project read/write access,
- correct the configured Project number/owner, or
- remove
github.projects.v2when coordination is not required.
- Doctor must make the next operator action explicit. At minimum, say whether they need to:
- Map shared utility outcomes into doctor severity
required: false=> doctorWARN. Repository-local GitHub issue/PR flows remain usable while Project coordination is degraded.required: true=> doctorFAIL. The same Project validation failure blocks Lisa readiness because coordination was configured as required.
Good output examples:
WARN github.projects.v2: Resource not accessible by integration
Observed: exact GitHub / GraphQL failure text preserved from resolve-project.
Remediation: grant the token Project read/write access or remove github.projects.v2.required.
Repository-local GitHub issue/PR flows remain usable; Project coordination is disabled.
FAIL github.projects.v2: github.projects.v2.owner.slug must match github.org in v1
Remediation: use a Project owned by CodySwannGT or remove github.projects.v2.
Minimum automation-readiness checks
Doctor's automation-readiness group stays read-only: it audits whether this repo and runtime could
support /lisa:setup-automations and the resulting recurring jobs, but it does not create,
edit, delete, or reconcile automations on the default doctor path.
- Resolve the queue inputs exactly as setup-automations would
- Resolve the PRD queue from merged
source. - Resolve the build queue from merged
tracker. - Resolve the repair queue from the same queue-detection rules as
lisa-repair-intake(identical source-dispatch contract tolisa-intake). - If any automation would require guessing because
source,tracker, or their vendor keys are still unresolved after the config-readiness audit, report that automation asFAILrather than pretending scheduling can proceed safely.
- Resolve the PRD queue from merged
- Audit the current runtime's native scheduler surface without mutating it
- Codex: doctor should report whether the runtime exposes the native automations surface
(
automation_update) needed by/lisa:setup-automations. - Claude: doctor should report whether the runtime exposes
/schedule. - Other runtimes: doctor should explicitly say that no native Lisa scheduler is known for the current runtime.
- This is observability only. Never create a placeholder automation just to prove the scheduler works.
- Codex: doctor should report whether the runtime exposes the native automations surface
(
- Check exploratory-automation support by shipped stack surface
exploratory-bugsis supported only when the project ships anexploratory-qacommand surface (theexpo,rails, orharper-fabricstacks today). Reuse the same stack/support rule documented bysetup-automations; do not invent exploratory jobs for stacks that do not ship that command.- When the repo does not ship
exploratory-qa, reportexploratory-bugsasSKIPwith the reason. exploratory-prdsremains applicable when the repo can run/lisa:project-ideation; if its queue/config prerequisites are unresolved, report the exact blocking config fact.
- Severity ladder
PASSwhen an automation's queue inputs are resolvable and the runtime exposes the required native scheduler surface for that automation.WARNwhen Lisa remains usable manually, but the current runtime has no native scheduler surface for unattended runs, so automation setup would be unavailable from here.SKIPwhen an optional automation is intentionally unsupported for this repo surface (for example,exploratory-bugson a stack with noexploratory-qacommand).FAILwhen the repo's config cannot resolve the queue that an automation needs, because that would make unattended runs ambiguous or broken before scheduling even starts.
Minimum wiki-delegation checks
When a repo-local wiki/ directory exists, doctor must surface the specialized wiki-readiness path
without turning the base doctor into a second lisa-wiki-doctor.
- Detect whether wiki delegation applies
- If no repo-local
wiki/directory exists, report the entire wiki group asSKIPwith the reason that no wiki surface is present in this repository. - If
wiki/exists, keep the group present in the final report; do not silently omit it.
- If no repo-local
- Prefer summary of an existing specialized verdict
- If the repo already has a readable
wiki/state/migration/doctor-report.json, doctor may summarize the specialized verdict (READY,READY_WITH_WARNINGS, orNOT_READY) plus the most relevant blocking/warning facts, clearly attributing them tolisa-wiki-doctor. - Preserve the base doctor's narrower scope: summarize or quote the specialized verdict, but do not inline the full migration/readiness checklist into the base doctor output.
- If the repo already has a readable
- Otherwise advertise the deeper follow-up explicitly
-
If
wiki/exists but no specialized report is available yet, doctor must still tell the operator that deeper wiki checks live behindlisa-wiki-doctor. -
The report should make the next action explicit, for example:
WARN wiki-follow-up: wiki/ detected; deeper wiki migration checks not yet summarized Observed: wiki/ exists, but no wiki/state/migration/doctor-report.json was found. Remediation: run lisa-wiki-doctor to produce the wiki-specific readiness verdict.
-
- Severity ladder
SKIPwhenwiki/is absent.PASSwhenwiki/exists and doctor successfully summarizes an existinglisa-wiki-doctorverdict.WARNwhenwiki/exists and doctor can only advertise the specialized follow-up because no persisted wiki verdict is available yet.FAILonly whenwiki/exists but the repo cannot surface the specialized follow-up at all (for example, the requiredlisa-wiki-doctordistribution surface is missing or the existing report is unreadable/malformed enough that doctor cannot safely summarize it).
- Keep wiki readiness optional for non-wiki repos
- Never require a wiki plugin surface when
wiki/is absent. - Never let wiki-specific checks downgrade unrelated non-wiki repositories.
- Never require a wiki plugin surface when
Minimum repository-readiness checks
The eight groups above answer one question: is Lisa installed correctly here? There is a second,
orthogonal question — may an agent fleet operate here unattended? — and conflating them is how a
brownfield onboarding ends in "we built a wiki, looks good" instead of a verdict someone can act on.
Repository readiness is that second question, and it is opt-in behind the --readiness flag; the
default doctor path never renders it and stays byte-identical.
-
Render one separately-titled group. When readiness mode is requested, append a single
Repository readinessgroup (idrepository-readiness) in a fixed position after the eight installation groups, using the sharedcreateRepositoryReadinessDoctorGrouphelper fromscripts/doctor-report.mjs. It is distinct from the installation groups so a reader is never left guessing which question a verdict answered. -
Score exactly the eight ownership dimensions from
readiness-rubric. Report eight checks, in fixed order, never fewer and never silently omit one:context-routing,capabilities-tools,domain-ownership,execution-proof,feedback-guardrails,dependencies-supply-chain,delivery-authority,proportionality. Cite thereadiness-rubricslug for the dimension definitions, the seven ship blockers, and the consequence-ordering contract; do not restate or fork that vocabulary here. -
SKIPcarries a reason and is never blank. A dimension with no applicable evidence rendersSKIPwith a stated reason ("no deployment target configured, so delivery/authority was not assessed"). An unassessed dimension is a known unknown, and the report says so.This surface reflects the CLI; it does not re-score. The evidence producers and the blocker engine live in the Lisa CLI, which is the single source of truth. When
.lisa/readiness.jsonis present and readable (parses, matchingschema_version, carries adimensionsarray), this group projects each recorded dimension's status and its operator-facing evidence/reason text, so an operator running/lisa:doctorthrough any coding agent sees the same readiness answer the CLI gives. When the report is absent, unparseable, or stamped with an unknownschema_version— or records nothing for a given dimension — that dimension falls back toSKIPwith the reason it was not assessed. Absence means the readiness pass has not run, never that the repository is clean: a pass or a fail is never manufactured from a missing report. Runlisa doctor --offline --readinessto produce the report first.A standing blocker outranks the recorded status. Some CLI producers record
WARNwhile standing a ship blocker — the blocker engine never reads the per-dimension status, so the finding flips the repository toNOT_READYexactly as aFAILwould. A dimension that owns an entry in the report'sblockers[]therefore projects asFAILregardless of its recorded label, and its remediation names the blocker ids and repeats the report'snarrowed_claim(the "IS ready for supervised, single-ticket agent work" fallbackreadiness-rubricrequires whenever a blocker stands). Projected checks also carry the report'sgenerated_at/lisa_versionso a stale report cannot read as current truth, and unassessed dimensions carry the CLI's not-established caveat. -
Reuse the shipped verdict ladder and consequence ordering. No new verdict value and no new severity: reuse
READY/READY_WITH_WARNINGS/NOT_READY.READYrequires positive evidence — every readiness dimension assessed and clean, with no blocker standing. An unassessed (SKIP) dimension is silence, not health, so any readiness report carrying one tops out atREADY_WITH_WARNINGS; only a standing ship blocker yieldsNOT_READY. Section order stays stable; the findings within each dimension check order highest-consequence-first. -
Persist to a versioned, relocatable artifact. Writing the report to
.lisa/readiness.json(schema_version: 1, withverdict,blocker_count, and per-dimension findings) is resolved through a single resolver so the location is one line to change. The write is atomic and must never fail the run: a write error degrades to aWARNcheck rather than throwing. -
Warn-only, always. This gates a claim, not a process: readiness never hard-blocks and never changes doctor's exit-code semantics (exit 1 iff some check is
FAIL). Where a surface named here is not installed, degrade — state what was assessed, state what was not, and continue.
Upstream Lisa change-history diagnosis
A failing or warning check has two possible causes: the project drifted, or Lisa itself changed upstream since this project last updated. Doctor must distinguish them instead of blaming the project by default. Whenever findings need explanation — and always before proposing repairs — attribute the finding with real evidence.
The attribution procedure itself is shared: it lives in the lisa-attribute-failure skill
(extracted from this section so any failure event can be attributed, not only doctor findings).
Doctor invokes it per finding:
- Resolve the version window. Determine the project's installed Lisa version (the
@codyswann/lisaentry inpackage.json/lockfile, or the plugin version stamp on the active runtime) and the latest published version (npm view @codyswann/lisa version, or the update check's cached result). - Invoke
lisa-attribute-failurewith the finding as the failure event: the defect description, the implicated template/config paths, the rule/skill/hook in play, and the resolved version window. The skill evaluates its three ordered signals — Lisa-managed surface ownership, shipped rule/skill/hook behavior, and the upstream change-history window (the version-window compare procedure formerly documented inline here, preserved verbatim in that skill: pagination/--slurphandling, targeted per-SHA diff context, truncation caveats, and the bounded explicit-tag fetch fallback) — and returnslisa|project|ambiguousplus the cited evidence. - Map the verdict into the finding.
lisa— Lisa changed the contract (a tightened lint rule, a renamed check context, a new required config key) or shipped the defective surface. Say so inObserved:with the cited evidence (commit subject/version or managed-surface proof), and letRemediation:point at the sanctioned adoption path (e.g.lisa update+ re-apply, a documented config opt-out) rather than hand-editing managed files.project— history shows no relevant upstream change and the surface is project-owned: the project drifted, so remediate on the project side.ambiguous— history was unavailable, truncated, or otherwise inconclusive: report the gap as aWARN-level observability note with the evidence gap named — never fail the audit because history was unavailable or incomplete, and never attribute drift with unverified confidence.
This diagnosis remains part of doctor's read-only contract: the attribution skill reads Lisa's repository, never writes to it, and repair suggestions stay suggestions.
Output contract
The final report must:
- Separate observed facts from remediation advice.
- Print every check with one of
PASS,WARN,FAIL, orSKIP. - Emit exactly one overall verdict:
READY,READY_WITH_WARNINGS, orNOT_READY. - Stay read-only by default.
Render the report in grouped sections using the shared scripts/doctor-report.mjs contract:
- Start with
Overall verdict: <VERDICT>and oneCounts:line coveringPASS,WARN,FAIL, andSKIP. - Then print each group as
<group-id>. <group-title>. - Under each group, print one line per check as
- <STATUS> <check-id>: <summary>. - When available, print
Observed:andRemediation:lines beneath the check so the report keeps facts separate from advice. - If a group has no applicable checks yet, render it as a grouped
SKIPwith the reason instead of silently omitting the section.
The verdict ladder is:
READY— noFAILand noWARN.READY_WITH_WARNINGS— noFAIL, but one or moreWARN.NOT_READY— one or moreFAIL.
Gate configuration
node scripts/lisa-gates.mjs validate # shape + unknown gate ids
node scripts/lisa-gates.mjs list --moment=pull-request
node scripts/lisa-gates.mjs contexts # branch-protection contexts
node scripts/lisa-gates.mjs skip-jobs # what replaces each skip_jobs token
A gate is a property — credential leakage — not a tool. gitleaks is one way to prove
that property, and which way is the project's choice: each gate names a task in the project's own
runner, so swapping the tool changes one line of project config and nothing in Lisa.
validate refuses an unknown gate id rather than ignoring it. A misspelled credential-leakge
would otherwise read as an enabled guarantee and run nothing at all — the same silent-hole shape
as a skipped required check. Gates a project invents carry an x- prefix, which Lisa runs without
pretending to understand.
skip-jobs answers the migration off the skip_jobs workflow input. Eleven of the token → gate
pairs cannot be recovered by transforming the name — lint is code-style, sg_scan is
structural-rules, work_item_traceability is traceability — and being wrong does not break a
build: it declares the WRONG gate off, so a check silently stops running while the configuration
reads deliberate. Pass --moment= to match the moment the caller passes to quality.yml, because a
gate's legal moments are a closed set and a declaration outside it is refused by validate — after
the token has already been deleted.
Four of its six answers are refusals, and they are the point. unmappable means the job was never
converted to a gate façade, so keep the token. partial means one job behind the token has a gate
and the others do not, so the declaration alone would leave work running. inert means no job
honours that token at all. unknown means nothing matches it — usually a space after a comma, which
GitHub matches against nothing, so that job has been RUNNING all along. None of them produce a gate
id, because a confident wrong answer here is worse than no answer.
lisa doctor reports the same thing per caller workflow and does NOT edit the workflow. lisa apply
runs on postinstall, and an automated rewrite of a caller that gets it wrong is silent; an agent
performing the edit can read the surrounding code and confirm afterwards that the same checks ran.
contexts is the value that replaces a hand-transcribed branch-protection list. It is scoped to
one environment: a gate required before a production deploy is not thereby a merge blocker on a
pull request, and collapsing the two would promote every deploy-time gate into branch protection.
When reconciling a ruleset against it, pass --previous= with any label retired in the last
release. Downstream repositories call the shared workflow unpinned, so a renamed job reaches every
repository before any of them has reconciled — and a required context that never reports leaves
pull requests waiting indefinitely. The fastest way out of that is deleting the requirement, which
is how a rename ends up removing a guarantee. Emitting both labels for one release avoids it.
Reconciling against the live ruleset
contexts says what the repository should require. Reconciliation asks whether GitHub agrees.
The comparison also runs without anyone typing it. A control reachable only by a human reading a code block is indistinguishable from one that was never written, so the declaration is held against both enforcing surfaces on paths a machine already takes:
| surface | who runs it | needs the network |
|---|---|---|
| the shipped ruleset template a repository is provisioned from | lisa doctor (one operator-readable line) and lisa doctor --json → declarationDrift.templates |
no |
| the live branch-protection ruleset | lisa doctor --json → declarationDrift.live, and the github.declared-checks check in lisa health |
yes |
Both report one verdict per required context, and the vocabulary is seven values rather than matched/unmatched, because the mismatches and an unreadable surface need different actions:
matched— declaredrequired, and the surface requires it.declared-not-enforced— the settings file says it must pass and nothing requires it. The declaration blocks nothing.enforced-declared-off— the settings file says the property is deliberately not proved here, and protection requires it anyway. A contradiction, not a gap.enforced-undeclared— protection requires it, a registry gate produces it, and no declaration governs it. Silence is not permission to stop requiring it; the remedy is to declare the gate.enforced-declared-optional— protection requires it while the declaration says optional. Name which surface wins.enforced-not-lisa-owned— protection requires it and no registry gate produces it. Third-party checks are required by construction and declared by nobody; this bucket exists so they can be told apart from a Lisa gate that fell out of the settings file, never so they can be removed.unproven— the surface could not be read, so no enforcement comparison was made. It is never a match and never drift; the operator fixes access/availability and reruns the observation.
enforced-declared-off and declared-not-enforced fail the health check; the two gaps warn. No
remedy in that vocabulary removes a required context — the type has no such member, so the
guarantee holds for every caller rather than for the careful ones. A surface this run could not
read is reported as unproven and is never a match.
Reconciliation is wired, not something an agent has to remember to run: it is step 5 of
scripts/lisa-github-repo-setup.sh, alongside the settings baseline and the rulesets themselves,
and it is exposed as npm run policy:reconcile. Until #2917 its only callers were the code blocks
below — prose in this very file — so the declared configuration converged when somebody happened to
read a document. Run it by hand for the flags:
node scripts/lisa-reconcile-policy.mjs --dry-run # read-only: what would change
node scripts/lisa-reconcile-policy.mjs --on-drift=report # report, write nothing
node scripts/lisa-reconcile-policy.mjs --on-drift=block # exit 1 on any drift, for CI
node scripts/lisa-reconcile-policy.mjs # honors policy.on_drift
node scripts/lisa-reconcile-policy.mjs --previous="🧽 Lint" # keep a renamed context required
node scripts/lisa-reconcile-policy.mjs --prune # also remove EXTRA contexts
It reads the live ruleset and repository settings through gh, compares them against the derived
contexts and the policy block, and reports three sets: MISSING (declared, not live), EXTRA
(live, not declared), MATCHED. --dry-run never writes, whatever policy.on_drift says.
Three behaviors need an operator to understand them before reading a report.
1. UNPROVEN is not a pass. If gh is missing, unauthenticated, or the API errors — a private
repository on a plan without rulesets answers 403 — the verdict is UNPROVEN and the exit code is
2. It is neither of its neighbours, and the distinction is the same one the secrets preflight
draws with unreachable: reported as clean it is a vacuous green, clean precisely because nothing
was learned; reported as drift it sends someone to fix a ruleset that may be perfectly correct. The
drift sets come back null rather than empty, because empty is what a clean repository looks like.
on_drift does not soften this — not even report — because on_drift decides what to do about a
drift that was measured, and here nothing was. Map it to doctor's WARN/FAIL on the same rule
as any other unavailable check surface: never PASS.
2. An EXTRA context is reported, never removed. Lisa does not own the whole required list.
Under repair the script ADDS what is missing and leaves what is extra alone, naming each one.
Removing them requires --prune, and the right way to clear the list is one at a time: each EXTRA
context is either an app to keep, or a check that belongs in .lisa.config.json — decide which
before pruning anything.
A context posted by an external app no longer HAS to be EXTRA. SonarCloud Code Analysis,
GitGuardian Security Checks, and CodeRabbit are declarable two ways, and either one makes the
reconciler treat them as MATCHED rather than as a permanent false alarm:
"gates": {
"credential-leakage": {
"pull-request": {
"level": "required",
"await": "GitGuardian Security Checks",
"posted_by": 46505
}
}
},
"github": {
"rulesets": {
"requiredChecks": {
"quality checks": [{ "context": "🧩 Plugin artifacts match source" }]
}
}
}
An await names the gate the signal PROVES, so declaring nothing requires nothing — which is how a
project that proves credential leakage with a different scanner says so. posted_by is the GitHub
App id allowed to post that context; omit it and the context is required unpinned, GitHub's "any
source". requiredChecks is for a repository-specific check that no gate models; it replaces the
additive-only addRequiredChecks, which is still read but can never express "stop requiring this".
Both surfaces name the ruleset a repair writes to, so --ruleset is needed only for a
gate-DERIVED context on a repository whose rulesets are ambiguous.
2b. A gate proved OUTSIDE the quality facade names its own check-run chain. A run-mode gate's required context is derived by prefixing the gate's label with the chain of jobs that reach the facade. When a project proves one property from a workflow of its own instead, that chain describes a route that never reaches the prover, and the derived name is one nothing posts — which does not turn a pull request red, it holds it at "Expected — Waiting for status to be reported" for ever. Declare the real chain on that moment:
"gates": {
"e2e-browser": {
"run": "test:e2e:pr",
"pull-request": {
"level": "required",
"caller_chain": ["🎭 PR Browser Coverage"]
}
}
}
Read the chain off a completed run, never off the YAML: a check run's reported name is the
/-joined chain of the JOB names that reach it, outermost first, and the top workflow's own name:
never appears in it. Take it from
gh api repos/OWNER/NAME/commits/SHA/check-runs --paginate --slurp, or let
lisa-gates.mjs verify-contexts --posted=<file> compare a derivation against what actually posted.
Either spelling works — ["A", "B"] or "A / B" — and the override replaces the caller's chain for
that gate only; every other gate keeps deriving exactly what it derived before. It is declared per
MOMENT, because the same job posts a one-level name on the pull-request path and a two-level one on
the release path. Declaring it on the whole gate, on an awaiting moment, at a moment that posts no
status, or with a blank level is refused by lisa-gates.mjs validate — at declaration time, which
is the last point at which any of those can still fail loudly.
3. Keep both names during a rename. --previous=<old label> requires the old and the new
context simultaneously for one release. Without it the reconciliation reports the still-live old
context as EXTRA (and --prune would delete it) while in-flight pull requests wait on a context
that will never report again.
Repair writes exactly two things: required contexts on a ruleset, and repository settings. Policy
carried by the shape of a rule — linear history, signed commits, force-push and deletion
protection, conversation resolution — is compared here and repaired by
scripts/lisa-github-rulesets.sh, which owns rule construction; the reconciler reports those and
names that script rather than reshaping rules it did not build. When more than one ruleset requires
status checks it refuses to guess which owns the derived contexts and asks for --ruleset=<name>,
because writing to the wrong one enforces a context under a different ref-name condition.
That rule construction is itself config-driven now. all/github-rulesets/base.json is deleted; the
base ruleset is generated per project from policy — including policy.ruleset.enforcement, the
policy.ruleset.include_refs / exclude_refs conditions, policy.ruleset.bypass_actors, and
policy.review.required_approving_review_count, none of which config could express while the
template owned them.
policy.review.require_extra_approval_for_unattributed_changes is declarable for a reason worth
stating, because its default is not "leave it alone". Measured against the live rulesets API on
2026-08-25: a pull_request rule sent WITHOUT the parameter came back with it true; set
explicitly to false it came back false; sent again without it, it came back true. GitHub
re-applies its own default on every write that omits the field. So an operator who wants it OFF
cannot hold it off unless the payload names it, and an operator who wants it ON is recording no
choice at all — the value is whatever GitHub's default happens to be. Declaring it pins the choice
and makes it visible to the reconciler; declaring nothing keeps the payload byte-for-byte what it
was, parameter absent.
A ruleset that governs no branch
Reconciliation asks whether the required LIST matches. It never asks whether the ruleset carrying
that list matches any ref the repository actually has, and GitHub does not ask either: a
conditions.ref_name.include entry is a pattern, not a reference, so refs/heads/dev is accepted
and stays live on a repository whose only branch is main. Everything that ruleset requires is then
required nowhere — the gate still runs and posts its context, and no pull request waits on it.
Every surface read that as healthy. compareRulesets in lisa health compares the include list
against Lisa's template by string equality, and both say refs/heads/dev, therefore no drift; the
repo-map row parses includes but matches the literal ~DEFAULT_BRANCH and ORs across rows, so one
governing ruleset makes the whole answer green. Nothing enumerated the repository's branches.
The github.ruleset-reach check in lisa health now does, and so does
scripts/lisa-github-rulesets.sh at the end of every apply, dry run included. Both load one shipped
detector, scripts/lisa-ruleset-reach.mjs, so the two surfaces cannot disagree about which rulesets
govern nothing. Not in doctor: doctor makes no network call, and which branches exist is a live
read — the same split that puts the template half of the declaration comparison in doctor and the
live half in health.
Three answers, never two. A ruleset that matches at least one branch is silent. One that matches
none is named with the patterns that matched nothing. One this run could not classify — an
unreadable branch list, a ~DEFAULT_BRANCH whose default branch could not be resolved, an fnmatch
bracket expression the detector does not model — is undetermined and says so, because an unread
branch list is not an empty repository and reporting it as one would name every ruleset present.
Report only, and neither obvious repair is Lisa's to make. Creating the missing branch manufactures the very ref the ruleset was written to protect; disabling the ruleset gives up a protection somebody chose. An automated actor may narrow a control, never loosen one. Point the ruleset at a branch the repository has, or retire it deliberately.
Secrets configuration
Run the secrets health checks through the skill that owns the contract, rather than reimplementing any part of it here:
node .claude/skills/lisa-secrets-access/scripts/validate-config.mjs # shape
node .claude/skills/lisa-secrets-access/scripts/preflight-secrets.mjs # credential readiness
node .claude/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs # tooling readiness
node .claude/skills/lisa-secrets-access/scripts/doctor-secrets.mjs # health
Run the validator first. It checks only the shape of the secrets, remoteEnv, and
automations blocks — whether a declaration could ever be correct — and catches the failures that
would otherwise surface somewhere unhelpful: a container failing mid-setup, a scheduled loop that
never fires, a dispatch naming a surface nobody provisioned. The health check then asks whether the
credentials actually resolve.
The preflight in the middle is the same check the SessionStart hook runs, and it is here for the half the hook deliberately cannot do. The hook injects its verdict into an agent's context and never blocks — killing a session over a credential it may not need would tax every session for a minority need, and a control people route around enforces nothing. Doctor is where the same verdict is allowed to be a non-zero exit.
It reports three outcomes, and the third carries the weight. ok and missing are self-evident.
unreachable means the provider itself could not be asked — no CLI, no bootstrap token, an API
that errored — and it is neither of its neighbours. Reported as ok it would be a vacuous green,
clean precisely because nothing was learned. Reported as missing it would blame the vault for a
fault in this machine's access and send someone to grant a credential that was never absent. Both
fail; they differ in what they tell the reader to fix.
The tooling preflight asks the same question about CLIs, and it is a caller of
planToolchain rather than a second implementation — that distinction is the point.
verify-remote-env.mjs previously ran its own toolchain loop which never consulted
minVersion, so a container verified clean against a node older than the manifest
demanded while the plan-side check rejected exactly that. One function now answers the
question everywhere.
It needs only two verdicts. A local binary cannot fail to be asked the way a vault can,
and the analogous trap — a tool present at an unparseable version — already fails closed
upstream, because an unknown version loses every minVersion comparison. What it does add
is a split credentials have no equivalent for: a missing tool with a pinned, checksummed
install entry is something Lisa can place itself, so it reports an action and exits zero,
while a tool nothing can provision blocks and exits non-zero.
It reports without ever printing a value, and compares two copies of the same credential by digest.
Map its findings into the doctor's own verdicts: error → FAIL, warn → WARN.
A project with no credentials manager is WARN, never FAIL. The env provider is a supported
state — the environment is the provider. A manager is the preferred path because it gives one
store, rotation, and an audit trail; it is not a requirement, and doctor must not act as though it
were.
The check worth understanding before triaging its output is the two-store one. A value present in both the provider and a local copy is not a duplicate — it is two live credentials, one of them untracked. Both authenticate, so the difference is invisible from either side, and "tidying up the duplicate" deletes a working credential that no record accounts for. Treat a mismatch as stop-and-ask, never as something to adjudicate automatically.
Delegation and reuse
- Reuse
config-resolutionfor config and lifecycle role defaults instead of inventing a second schema. - Reuse
lisa-secrets-accessfor every secrets check. Doctor must never read a keychain, parse a.env, or invoke a provider CLI itself — one chokepoint is what makes the single-store rule enforceable, and a diagnostic that bypasses it is exactly the second reader that creates drift. - Reuse the existing
github-project-v2chokepoint for GitHub Project coordination checks instead of inlining bespoke access logic. - Reuse ideas from
lisa-wiki-doctorfor grouped verdict rendering where they fit, while keeping the Lisa-wide doctor narrower than the wiki-specific migration/readiness workflow.
Rules
- Never mutate repository, tracker, or automation state on the default doctor path.
- Never hardcode tracker/source label names outside the documented defaults plus configured
overrides from
config-resolution. - Never silently treat an unavailable check surface as success; report
WARN,FAIL, orSKIPwith the explicit missing dependency. - Never turn wiki-specific checks into a requirement for non-wiki repos.