Skip to content

Detect BMAD at Module granularity during install, and never fail the install for it - #371

Draft
Hidden-History wants to merge 9 commits into
mainfrom
feat/story-1-5-bmad-module-detection
Draft

Hidden-History wants to merge 9 commits into
mainfrom
feat/story-1-5-bmad-module-detection

Conversation

@Hidden-History

Copy link
Copy Markdown
Owner

What

scripts/install.sh now reports BMAD availability at Module granularity rather than
treating the BMAD root as a single present/absent signal. Four states are distinguished,
each with its own named report:

  • BMAD absent
  • BMAD present / BMM absent
  • BMM present
  • BMAD evidence unreadable or unresolvable -> undetermined

Why

The previous check treated any unreadable or non-standard _bmad layout as "absent",
which is wrong in both directions. An operator with a working BMM install but an
unreadable ancestor directory was told BMAD was missing; a symlink pointing at a regular
file was reported as a working install. Both are confident wrong answers the operator has
no way to detect.

The install must also never fail because of this check. Detection and reporting both
return zero in all four states, including undetermined -- a diagnostic about an optional
dependency is not grounds to abort an install.

Scope

  • scripts/install.sh -- detection and reporting
  • tests/test_install_bmad_module_detection.py -- new; covers all four states and the
    boundaries between them
  • INSTALL.md -- documents the four states and what each means for the operator
  • CHANGELOG.md -- Added entry

Additive only: 1448 insertions, 0 deletions.

Notes for review

Opened as a draft. This is the first CI run against this branch -- the repository's
workflows trigger on pull_request to main, so none of these commits has been exercised
by CI before now.

AC-3 ("BMM present -> no warning at all") is knowingly violated in exactly one state: a
repository that has BMM but whose evidence cannot be read resolves to undetermined and
warns. This is declared in the reporter header in install.sh and grounded in a project
decision that postdates the criterion.

Hidden-History and others added 9 commits August 30, 2026 03:03
The installer said nothing about BMAD, so a project carrying most of BMAD but
missing the BMM Module looked identical to a fully-equipped one. The gap only
surfaced later, as a failed dispatch, with nothing at install time to point at.

Detection now resolves three distinct states rather than present/absent: no BMAD
at all, a BMAD root without the BMM Module, and BMM installed. The absence
message names BMM as the required Module instead of referring to BMAD in
general, so an operator knows which piece to add. Presence is silent — a
reassuring "detected" line is treated as a defect, not a courtesy.

BMAD is not a prerequisite. Its absence is a supported operating state, so the
detection path cannot change the install's exit status: the detector carries its
answer on stdout and returns zero unconditionally, and the call site consumes it
in a condition so nothing can escape into errexit and abort a run mid-way. The
result is not written anywhere, which keeps installing BMAD afterwards from
requiring a reinstall.

The surface matters and is chosen deliberately. Skill directory names carry no
module segment, so they can answer "BMAD?" but never "BMM?". The BMAD root
config is not a module registry — it names a subset of installed modules, so
reading it reports BMM missing on a machine that has it. The module tree is
used instead, discriminating on the Module's own config rather than a bare
directory.

The absence message deliberately carries no upstream source and no version
string; that half of the operator's next step is owned separately.
Module detection treated "the BMAD directory could not be read" as
"the BMM Module is not installed". A directory present but not
traversable satisfies the existence test while every test beneath it
fails with EACCES, which the shell cannot distinguish from the file
not existing, so an operator whose machine has BMM was told BMM was
missing. That is the same inverted report the detector already rejects
the BMAD root config for producing.

Unreadable evidence is now reported as its own state, saying the
availability of BMM is unknown and explicitly not that it is absent.
Detection still never changes the installer's exit status.

Also in this change:

- A missing project-path argument no longer aborts the caller under
  `set -u`; it resolves to the undetermined state rather than silently
  answering about the current directory.
- The module is certified from a non-empty config file, so a zero-byte
  config no longer certifies a half-written install as present.
- The state dispatch has a default arm, so an unrecognised state is
  reported instead of producing the silence that means "present".
- The comment citing the failure-taxonomy decision claimed it applied
  only by analogy. It binds every detector, and this is one. Corrected,
  keeping the narrower claim that was accurate: its enumerated
  fail-open list does not contain this condition. The neighbouring
  citation for the absent-registry decision is by analogy and stays so.
- The comment claiming the guarded call site added a second, independent
  guarantee was wrong: invoking a function as the left operand of `||`
  suspends errexit inside its body, replacing that guarantee rather than
  backing it up. The trade is stated instead of misdescribed.
- Portability wording no longer claims containment it does not have:
  the tests follow symlinks, which a shared BMAD install relies on.

Tests: the module goes from 23 to 34 cases, including the states above,
a no-argument call, a regular file where the BMAD root is expected and a
dangling module symlink. The harness now observes a non-zero return
instead of aborting before it can be read, environments are pinned so an
exported log level cannot flip results, and fixtures that cannot
construct unreadable evidence fail loudly rather than skipping green.

Installation notes now show the actual message text and stream, cover
the empty-module and undetermined states, and scope the exit-status
claim to this check rather than to the installer as a whole.
…lity guard

The Module-presence check accepted any non-empty path at the config
location, so a directory in place of config.yaml (a botched merge, an
aborted extraction, a mkdir typo) was reported as the Module being
installed. The check now also requires the path to be a regular file.

The BMAD-root and Module-directory readability guards required both
read and search permission, but the detector only ever performs named
lookups on known children, which need search permission alone. On a
directory that is searchable but not listable, the guard reported
detection as undetermined even though the BMM Module was present and
fully resolvable, incorrectly emitting a warning on a repository that
has BMM installed. Both guards now require only search permission,
with a comment explaining why read permission isn't needed.

Added tests covering both cases: a directory in place of config.yaml,
and a search-only BMAD root with BMM present.
…sent

The Module detector concluded "bmad-absent" whenever the directory test on the
_bmad path returned false. That test cannot distinguish "there is no _bmad"
from "a parent directory refused us", so three reachable layouts reported a
confident absence on machines that do have BMM installed: a project directory
the installer cannot search, a _bmad symlink whose shared-install target cannot
be resolved, and a self-referential _bmad symlink.

All three now resolve "bmad-indeterminate", which is what the detector already
answered for an unreadable _bmad itself. The guard is a disjunction because
neither half covers the other: a symlink test catches the link cases, and a
search test on the project path catches the case where _bmad is a real
directory and the symlink test is blind to it. A project path that genuinely
does not exist still reports absent - there the answer was looked up and found.

The undetermined message told the operator to check that _bmad is "readable".
The gate is search permission, not read permission, so a directory at mode 444
- readable and listable but not enterable - named the one permission that was
not the problem. It now names entry permission, agreeing with INSTALL.md.

Header comments, all describing code that had moved underneath them: the
discriminator paragraph still presented the emptiness test as the single test
after the node-type test was re-added alongside it; the escalation receipt
cited no decision record and dropped half of the ruling it paraphrased; AD-36
was cited without the analogy marker its Binds line requires; and the fourth
state was described as a general "could not look" detector when what it detects
is permission denial observable from the project path downward. The residual
above the project path is now stated instead of implied. The knowing exception
to the no-warning criterion is declared where a reader re-derives that criterion.

Tests: one fixture's fail-loud guard probed a config.yaml the fixture never
created, so it could never fire and a filesystem that does not enforce modes
reported green instead of refusing to measure. The reporter's capture form had
no test at all, so rewriting it in a way that masks a failing detector left the
suite green; the new test uses a bare call, which is the only shape that can
observe the difference.
The guard added in the previous commit fired on any symlink at _bmad whose
directory test failed. That was too broad. A symlink pointing at a regular
file, a FIFO, a socket or a device resolves perfectly well - the answer is
"we looked, and it is not a BMAD root" - but it was reported as undetermined,
while a bare regular file at the same path was correctly reported absent. The
identical situation got opposite answers depending only on whether a symlink
stood in the way, which is the defect this guard exists to remove, one node
type over.

The guard now tests whether resolution succeeded rather than what the target
turned out to be. Testing for a regular file instead would still misreport a
FIFO, a socket and a device, all of which resolve.

Dangling links, symlink loops, and links whose target cannot be searched stay
undetermined, and they must: all three present identically here, because the
shell's file tests expose no errno and cannot separate a missing target from an
unreadable one from a loop. One of the three is a permission denial that has
already been ruled undetermined, so the other two take that answer rather than
being guessed apart.

Both previously unpinned directions now have tests, and the three candidate
forms of this guard are distinguishable by them: reverting to the broad form
fails the regular-file case, and narrowing to a regular-file test fails the
device case.
…, not absent

The root guard already treats a dangling, ELOOP, or unresolvable-parent symlink
at _bmad as bmad-indeterminate, because [[ ]] exposes no errno and cannot tell
ENOENT, ELOOP, and EACCES apart -- the adjudicated permission case rules all
three. The equivalent guard at _bmad/bmm never gained that limb, so the same
three shapes one level down fell through to bmm-absent, including the case
where the module's own config.yaml resolves and is non-empty. An operator whose
machine has BMM could be told BMM is absent.

detect_bmad_module_state's module-level guard now mirrors the root's -L/! -e
limb. The pinned test for a dangling module symlink asserted the old, incorrect
answer; it is rewritten to assert bmad-indeterminate, renamed, and moved beside
its root-level counterparts. A module-level fixture pair covering the
unresolvable-parent shape is added, mirroring the existing root-level fixture.
INSTALL.md and the indeterminate-state message are extended to name the Module
directory alongside _bmad, since both are now sources of this state.
….yaml

Same class as the previous commit, one node further down: a shared-install
layout can link only the Module's config.yaml rather than the whole bmm
directory, and a dangling or unresolvable-parent symlink there fell through
to bmm-absent for the same reason the Module directory guard did before it
gained the -L/! -e limb -- resolution failing is read as the Module not being
there, on a machine where it is.

detect_bmad_module_state gains a third guard, checked after the Module
directory resolves and before concluding bmm-absent, using the same
depth-independent standard as the two guards above it. Two tests cover the
dangling and unresolvable-parent shapes at this node, mirroring the existing
Module-directory fixture pair.
The dangling/unresolvable-symlink guards added at _bmad/bmm and
_bmad/bmm/config.yaml mirror the root guard's -L / ! -e limb, but every
existing symlink fixture at those two depths is dangling (-e false), so
the ! -e half of either guard was unpinned: deleting it left all 47
tests in the module green while turning every healthy shared install
into bmad-indeterminate. This is the same defect the root guard already
paid for once (83d9573 -> 9d73814).

Adds four tests mirroring the root's own regular-file / /dev/null
discrimination pair at each new depth. The config-depth resolved-symlink
fixture targets an empty file rather than a merely non-empty one: the
presence check (-f && -s) runs first and follows symlinks, so a symlink
resolving to a non-empty file is legitimately bmm-present.
The BMM-undetermined warning dropped the project-path clause ("it and")
that a prior round added on purpose when this round's two new node
names were appended to it; the project path is a live independent
cause of this state and needs its own pointer back.

Both the warning and INSTALL.md blamed the indeterminate state on
permissions alone, with no mention of the symlink causes these guards
exist to catch -- for a dangling or unresolvable symlink there is no
permission to fix, so that advice could never resolve it. Both now name
the dangling/unresolvable-symlink cause and its remedy (repair or
remove the link) alongside the permissions cause.

INSTALL.md's BMM-absent row also named "config.yaml is empty" without
distinguishing a zero-byte file (still absent) from a dangling symlink
(now indeterminate, not absent since the config-depth guard shipped);
reworded and cross-referenced. The undetermined row now names
_bmad/bmm/config.yaml explicitly -- the prior round documented the
Module directory guard but not the config.yaml guard shipped beside it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant