T-FZMY-extract-phrase-in-prose-shared-helper
Status: closed/done · Impact: low · Complexity: small
Any markdown linter that substring-matches required phrases against rendered prose is vulnerable to
line-wrap silently breaking the match. The motivating case is lint_skill_prose.py’s
required_phrases check, where the cross-repo-task-pr skill’s "never in its main checkout"
invariant failed because the source prose wrapped between “never” and “in”. Today only
lint_skill_prose.py does this kind of match; future prose linters (status-enum-in-prose,
shell-example-lint, etc. — all flagged by an earlier consolidation pass) will hit the same trap
unless the fix lives in a shared place. Land the normalize-then-match logic as a
plugin/lib/prose_match/ helper so the first linter benefits today and every subsequent prose
linter benefits by import.
| Location | Role today |
|---|---|
plugin/scripts/lint_skill_prose.py | Calls phrase in body where body is _section_text(...). Line-wrapped phrases silently fail to match. Concrete repro: cross-repo-task-pr skill’s "never in its main checkout" invariant. |
plugin/lib/ | Established home for shared importable modules (lease, fpid, entity_naming proposed). No prose_match/ or markdown-handling package exists. |
plugin/scripts/ | No other prose-prose linters live here today, but several are proposed (status-enum-in-prose, shell-example-lint). They’ll independently re-invent the whitespace problem without a shared helper. |
Proposed
Section titled “Proposed”A new plugin/lib/prose_match/ package exposes phrase_in_prose(phrase: str, body: str) -> bool
that normalizes whitespace on both sides (collapses any run of whitespace to a single space, strips
leading/trailing) before substring match. lint_skill_prose.py becomes the first consumer; the
existing direct-substring call is replaced by an import. The library carries a small fixture suite
covering: clean match, line-wrap match, leading/trailing whitespace, multiple-space collapsing, and
the always-fail case (phrase truly absent).
The helper is deliberately minimal — no fuzzy matching, no stemming, no fenced-code awareness. Those are separate concerns; the contract is “exact phrase, ignoring whitespace differences.” Future prose linters that need fenced-code stripping can compose this helper with a fenced-code stripper.
Approach
Section titled “Approach”- Create
plugin/lib/prose_match/__init__.pyexportingphrase_in_prose(and a co-located_normalize_whitespaceprivate helper). - Add
plugin/lib/prose_match/tests/test_prose_match.pycovering the fixture matrix above. - Update
plugin/scripts/lint_skill_prose.pyto import the helper; replace thephrase in bodycall withphrase_in_prose(phrase, body). - Re-run the full linter against
plugin/skills/and confirm no new false negatives (the test in AC-3 of the original spec). - Update
lint_skill_prose.py’s docstring to note that phrase matching is whitespace-insensitive.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/lib/prose_match/__init__.py | new | phrase_in_prose + _normalize_whitespace. |
plugin/lib/prose_match/tests/test_prose_match.py | new | Fixture matrix: clean match, line-wrap match, leading/trailing whitespace, multiple-space collapse, absent phrase. |
plugin/scripts/lint_skill_prose.py | modify | Import and use phrase_in_prose; update docstring. |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
plugin/lib/prose_match/exists;from prose_match import phrase_in_prose(via the project’s standard import path) returns the helper. The helper signature isphrase_in_prose(phrase: str, body: str) -> bool. - AC-2: A SKILL.md whose required phrase is split by a line break passes the linter (regression
test in
lint_skill_prose.py’s test suite, or inprose_match/tests/). - AC-3: A SKILL.md whose required phrase is absent still fails (no false negatives).
- AC-4: Running
lint_skill_prose.pyagainst the fullplugin/skills/tree produces zero new violations after the change. - AC-5: The helper’s own test suite covers at least: clean match, line-wrap match, leading whitespace, trailing whitespace, multiple spaces between words, completely absent phrase.
Out of scope
Section titled “Out of scope”- Fuzzy or stemming-based matching. The contract is “exact phrase, ignoring whitespace differences.”
- Fenced-code-block stripping. A separate composable concern; future prose linters that need it can
layer a stripper on top of
phrase_in_prose. - Migrating other prose linters to the helper. There are no other prose linters today; future ones will pick up the helper by import.
- Section-heading matching whitespace normalization. The current
section:hint matcher is case-insensitive substring; whitespace within headings is rare enough to skip.
Dependencies
Section titled “Dependencies”- none
Discovery context
Section titled “Discovery context”Originally framed as an inline fix to lint_skill_prose.py (rev 2026-05-21). Rewritten 2026-05-28
during an open/ready audit and following a consolidation subagent’s observation that the
SKILL.md-drift linter family (whitespace, status-enum, shell-example, mmdc-flag, etc.) shares the
substring-match-against-prose surface. Extracting a shared helper means future prose linters get
whitespace handling for free; the inline fix would have meant re-inventing the same fix N times.
Post-mortem
Section titled “Post-mortem”Captured by /sdlc:task-work on 2026-05-28. PR: pending.
Acceptance criteria coverage
Section titled “Acceptance criteria coverage”- AC-1: auto —
plugin/lib/prose_match/__init__.pyexportsphrase_in_prose(phrase: str, body: str) -> bool; the pytest run imports it viafrom prose_match import phrase_in_prose. - AC-2: auto —
test_line_wrap_matchinplugin/lib/prose_match/tests/test_prose_match.pyexercises a phrase split by a\nand asserts the helper matches; pytest run is green. - AC-3: auto —
test_absent_phrase_does_not_match(and the additionaltest_phrase_not_substring_when_normalized/test_phrase_absent_with_extra_words_in_betweenguards) confirm no false negatives. - AC-4: agent-manual — ran
find plugin/skills -name SKILL.md -print0 | xargs -0 ./plugin/scripts/lint_skill_prose.py; the only violations are two pre-existing drift items inplugin/skills/task-work/SKILL.md(LEASE-CONFLICT ref=<ref> owner=<other-host-id>missing in section 2a, andthreading.Thread(daemon=True)missing in section 6 — both unrelated to the whitespace fix; the skill recently switched from in-process threading to a subprocess heartbeat). Zero new violations introduced. - AC-5: auto —
plugin/lib/prose_match/tests/test_prose_match.pycovers the full matrix: clean, line-wrap, leading whitespace, trailing whitespace, multiple spaces, absent phrase (13 cases total including regression guards on tabs, mixed wrap whitespace, and empty-phrase semantics).
What worked
Section titled “What worked”- The
plugin/lib/_examplereference implementation and theprs_fieldpackage gave a clean template for layout, imports, docstring shape, and test discovery. Copying their shape made the new library a one-shot write. - The two-line bootstrap from
plugin/conventions/python-runtime.mdworked verbatim inlint_skill_prose.py; no debugging needed. - Baseline-gated quality checks via
--diff-against-baselinemade the gate noise-free even thoughaudit_entities.pyreports 232 pre-existing findings onorigin/main.
Friction and automation gaps
Section titled “Friction and automation gaps”- ensure-ready treated the
## Todayrow “Other prose linters” as a missing-file disqualifier, even though the row’s intent was descriptive (“no other prose linters live here yet”). The verifier’s existence check is strict-by-design, but tasks that want to describe an absence-of-thing in## Todayhave no documented escape hatch — the spec author has to pick a real directory as a stand-in. A future enhancement could let## Todayrows carry akind: prose-only(or a leading_to mark “descriptive, no path”) so the verifier skips existence on those rows. - The orchestrator-claimed lease was created by a different host-id than the current host;
acquire_leaseraisedLeaseConflictrather than inheriting. The user pre-empted this by instructing “use discover_lease(), don’t re-claim,” but a future task-work change could detect the orchestrator-dispatch case (e.g. via an env var likeSDLC_LEASE_DISPATCHED_BY_ORCHESTRATOR=1) and route through a same-flow that doesn’t require host-id equality. - The
chore(tasks): startcommit on main and the intermediateflag as needs-definitioncommit on the task branch conflicted at rebase time. The rebase recovered cleanly with a--skipof the now-stale needs-definition commit and a 2-line manual conflict resolution on the verify-stamp commit, but a fully-mechanical resolver could detect the “task branch has a stalestatus:value that main already supersedes” pattern and resolve it without operator action. run_quality_checks.py --diff-against-baselinedefaulted--baseline-dirto the worktree’s.sdlc/quality-baselines/, but the baseline was captured in the main repo’s.sdlc/. Had to pass--baseline-direxplicitly to point at the main-repo directory. The skill’s Step 7 invocation should pass--baseline-dir <project-root>/.sdlc/quality-baselines/explicitly so worktree runs find the baseline by default.