diff --git a/docs/superpowers/specs/2026-09-04-partial-morpheme-health-check-design.md b/docs/superpowers/specs/2026-09-04-partial-morpheme-health-check-design.md
new file mode 100644
index 000000000..ab6ede90f
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-04-partial-morpheme-health-check-design.md
@@ -0,0 +1,65 @@
+# Partial Morpheme Health Check
+
+## Goal
+
+Extend `GrammarHealthChecker` on pull request #475 to identify every partially
+analyzed HermitCrab morpheme. The finding should tell grammar authors to finish
+the incomplete analysis and explain that partial morphemes can broaden search
+and prevent safe final-template pruning.
+
+This is a production-readiness diagnostic. It does not change grammar loading,
+parsing, synthesis, or the conservative final-template correctness guard.
+
+## Diagnostic contract
+
+Add the stable code `hc-partial-morpheme` with warning severity. Emit one
+finding for each distinct `Morpheme` whose `IsPartial` property is true.
+
+The check covers:
+
+- lexical entries in every stratum;
+- ordinary morphemic morphological rules in every stratum; and
+- morphemic rules referenced by affix-template slots.
+
+The same rule object can be referenced more than once, so enumeration must use
+reference identity and report it once. Each finding's first and only subject is
+the partial `Morpheme`, allowing a host to navigate to the original object.
+
+The message identifies whether the subject is a lexical entry or morphological
+rule, names it using the best available identifier, and recommends supplying
+its missing category or template/slot analysis. It also states that leaving the
+morpheme partial can broaden analysis and disable safe final-template pruning.
+
+## Placement
+
+`GrammarHealthChecker.Check(Language)` will invoke a private partial-morpheme
+check alongside the two existing checks. The implementation stays inside the
+`netstandard2.0` HermitCrab library and remains diagnostic-only.
+
+No new parser option or model field is introduced. `Morpheme.IsPartial` remains
+the owner of the decision; the health checker reports that published fact and
+does not re-derive partiality from POS, slots, or feature structures.
+
+## Tests
+
+Tests will be written and observed failing before production code changes. They
+will prove that:
+
+1. a partial lexical entry produces one actionable warning and exposes the
+ entry as its subject;
+2. a partial ordinary affix rule produces one warning;
+3. a partial template rule produces one warning even if referenced by multiple
+ slots or templates;
+4. non-partial morphemes produce no partial-morpheme warning; and
+5. the existing checks continue to compose with the new check.
+
+The targeted HermitCrab suite and formatting check must pass before the branch
+is pushed.
+
+## Relationship to pull request #491
+
+Pull request #491 keeps its safe default: final-template pruning remains
+disabled wherever partial morphemes make the stronger conclusion unsafe. The
+new health finding gives grammar authors an actionable route to remove that
+performance blocker instead of weakening the correctness guard or silently
+forcing the optimization.
diff --git a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs
new file mode 100644
index 000000000..16b32db0c
--- /dev/null
+++ b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs
@@ -0,0 +1,325 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using SIL.Machine.Annotations;
+using SIL.Machine.FeatureModel;
+using SIL.Machine.Morphology.HermitCrab.MorphologicalRules;
+using SIL.ObjectModel;
+
+namespace SIL.Machine.Morphology.HermitCrab
+{
+ ///
+ /// Checks a loaded for problems HermitCrab does not otherwise report:
+ /// segments used without a declaration, declared segments with duplicate phonological feature
+ /// bundles, and morphemes whose analysis is marked partial. These problems can silently refuse
+ /// words, make morpheme identification unreliable, or broaden analysis enough to disable safe
+ /// final-template pruning. This checker surfaces them before the grammar ships. It is diagnostic
+ /// only: it never changes how a parses.
+ ///
+ public static class GrammarHealthChecker
+ {
+ ///
+ /// Runs every check against and returns the findings, in the
+ /// order the checks ran. An empty list means every registered check passed, not that nothing
+ /// was checked -- see for what each finding's code means.
+ ///
+ public static IList Check(Language language)
+ {
+ if (language == null)
+ throw new ArgumentNullException("language");
+
+ var findings = new List();
+ CheckDuplicateFeatureBundles(language, findings);
+ CheckUndeclaredSegments(language, findings);
+ CheckPartialMorphemes(language, findings);
+ return findings;
+ }
+
+ private static void CheckPartialMorphemes(Language language, List findings)
+ {
+ var seen = new HashSet(new ReferenceEqualityComparer());
+
+ foreach (Stratum stratum in language.Strata)
+ {
+ foreach (LexEntry entry in stratum.Entries)
+ CheckPartialMorpheme(entry, seen, findings);
+
+ foreach (Morpheme rule in stratum.MorphologicalRules.OfType())
+ CheckPartialMorpheme(rule, seen, findings);
+
+ foreach (AffixTemplate template in stratum.AffixTemplates)
+ {
+ foreach (MorphemicMorphologicalRule rule in template.Slots.SelectMany(slot => slot.Rules))
+ CheckPartialMorpheme(rule, seen, findings);
+ }
+ }
+ }
+
+ private static void CheckPartialMorpheme(
+ Morpheme morpheme,
+ HashSet seen,
+ List findings
+ )
+ {
+ if (!morpheme.IsPartial || !seen.Add(morpheme))
+ return;
+
+ string kind;
+ string name;
+ var rule = morpheme as MorphemicMorphologicalRule;
+ if (rule != null)
+ {
+ kind = "Morphological rule";
+ name = FirstNonEmpty(rule.Name, rule.Id, rule.Gloss);
+ }
+ else
+ {
+ kind = "Lexical entry";
+ name = FirstNonEmpty(morpheme.Id, morpheme.Gloss);
+ }
+
+ findings.Add(
+ new GrammarHealthFinding(
+ GrammarHealthSeverity.Warning,
+ GrammarHealthCodes.PartialMorpheme,
+ string.Format(
+ "{0} '{1}' is partially analyzed. Supply its missing category or template/slot analysis; "
+ + "leaving it partial can broaden analysis and disable safe final-template pruning.",
+ kind,
+ name
+ ),
+ new object[] { morpheme }
+ )
+ );
+ }
+
+ private static string FirstNonEmpty(params string[] values)
+ {
+ return values.FirstOrDefault(value => !string.IsNullOrEmpty(value)) ?? "unnamed";
+ }
+
+ // Every table's segments must have distinct phonological feature bundles, or a segment-changing
+ // rule cannot tell them apart.
+ private static void CheckDuplicateFeatureBundles(Language language, List findings)
+ {
+ // No feature system means every bundle is the same empty struct by construction (see
+ // PhonologicalBundle), not a collision.
+ if (language.PhonologicalFeatureSystem.Count == 0)
+ return;
+
+ foreach (CharacterDefinitionTable table in language.CharacterDefinitionTables)
+ {
+ List segmentDefs = table
+ .Where(cd => cd.Type == HCFeatureSystem.Segment)
+ .OrderBy(cd => cd.Representations.First(), StringComparer.Ordinal)
+ .ToList();
+
+ // ValueEquals is the model's own deep, order-independent feature-value equality.
+ var groups = new List>();
+ foreach (CharacterDefinition cd in segmentDefs)
+ {
+ FeatureStruct bundle = PhonologicalBundle(cd);
+ List group = groups.FirstOrDefault(g =>
+ PhonologicalBundle(g[0]).ValueEquals(bundle)
+ );
+ if (group == null)
+ {
+ group = new List();
+ groups.Add(group);
+ }
+ group.Add(cd);
+ }
+
+ foreach (List group in groups)
+ {
+ if (group.Count < 2)
+ continue;
+
+ string names = string.Join(", ", group.Select(cd => cd.Representations.First()));
+ var subjects = new List