From d6be58637176014714b68c9ae0568a92f4875c81 Mon Sep 17 00:00:00 2001 From: dnth Date: Thu, 15 Oct 2026 22:46:06 +0800 Subject: [PATCH 1/6] fix(ext): guard digit-based Discord choices Require buttons for numeric choice cues and labeled option lists. --- bin/fm-ext-emit.sh | 43 ++++++++++++------------------------- bin/fm-ext-lib.sh | 23 +++++++++++++++----- docs/configuration.md | 2 +- tests/fm-ext-ask.test.sh | 46 ++++++++++++++++++++++++++++++++++------ 4 files changed, 73 insertions(+), 41 deletions(-) diff --git a/bin/fm-ext-emit.sh b/bin/fm-ext-emit.sh index 4b36f425622..ca3ba847160 100755 --- a/bin/fm-ext-emit.sh +++ b/bin/fm-ext-emit.sh @@ -54,35 +54,20 @@ # the decision's current lifecycle (fm_ext_decision_key_occurrence in # bin/fm-ext-lib.sh), so the post can only ever answer that lifecycle. # -# CHOICE GUARD. An answer, followup, or final whose text has the shape of a -# choice is refused unless --decision-file is given, so a choice can never go -# out as plain numbered text by mistake. Detection uses these line-local, -# case-insensitive patterns: -# option line - a line, after optional indent and a "-", "*", or bullet -# marker, that starts with a 1-2 digit number ended by ".", ")", or ":" -# and optionally prefixed by "(" ("1." / "2)" / "(3)"), or a code of one -# letter plus 1-2 digits with an optional ".", ")", or ":" -# ("A1." / "B2:" / "B2 text"). The token must be followed by whitespace -# and non-whitespace text, or immediately by the end of the line. -# cue - "reply/respond/answer/type/send/pick/choose/select/tap/click", -# 1-40 intervening characters, then "number/code/letter/option/choice" -# (singular or plural), without crossing ".", "!", or "?", -# or "which one", "which option(s)", "pick one", or "choose one". -# Cue verbs and nouns match whole words (letters, digits, and underscore -# are word characters); intervening spans start and end with non-word -# characters. The fixed phrases use single spaces. -# Two or more option lines plus a cue anywhere in the text are refused. -# A standalone cue also suffices: "reply", 1-30 intervening characters, -# "with/using", 1-20 intervening characters, then -# "number/code/letter/option/choice" (singular or plural), with the same word -# boundaries and punctuation restrictions. No option lines are needed for -# this rule. A numbered list without a cue and prose satisfying neither rule -# post normally. --no-buttons "" posts a deliberate plain -# list anyway: the reason (one line, at most 200 characters) is recorded in the -# outbox payload as no_buttons_reason. It cannot be combined with -# --decision-file. The guard never checks an ack. -# +# CHOICE GUARD. An answer, followup, or final whose text has the shape of a choice is refused unless --decision-file is given, so a choice cannot go out as plain text by mistake. +# Detection is case-insensitive and uses these rules: +# option line - a line, after optional indent and a "-", "*", or bullet marker, that starts with a 1-2 digit number ended by ".", ")", or ":" and optionally prefixed by "(" ("1." / "2)" / "(3)"), or a code of one letter plus 1-2 digits with an optional ".", ")", or ":" ("A1." / "B2:" / "B2 text"); the token must be followed by whitespace and non-whitespace text, or immediately by the end of the line. +# choice cue - "reply/respond/answer/type/send/pick/choose/select/tap/click", 1-40 intervening characters, then "number/code/letter/option/choice" (singular or plural), without crossing ".", "!", or "?", or "which one", "which option(s)", "pick one", or "choose one"; cue words match whole words. +# digit cue - a reply, pick, choose, select, or say verb followed within 1-40 characters by at least two 1-2 digit numbers joined by commas, "/", or "or" (for example "1, 2 or 3", "2 or 3", "1/2", or "1 or 2"). +# choice heading - a standalone "Options", "Your call", or "Choose" line, with an optional colon and Markdown bold; two or more option lines must follow it. +# Two or more option lines plus a choice cue are refused, as is a digit cue by itself. +# A standalone reply cue also suffices: "reply", 1-30 intervening characters, "with/using", 1-20 intervening characters, then "number/code/letter/option/choice" (singular or plural), with the same word boundaries and punctuation restrictions. +# Numbered lists without a choice cue post normally, except when a choice heading is followed by two or more option lines. +# --no-buttons "" posts a deliberate plain list anyway: the reason (one line, at most 200 characters) is recorded in the outbox payload as no_buttons_reason. +# It cannot be combined with --decision-file. +# The guard never checks an ack. # Discord text is untrusted: pass it with --text-file or stdin. +# set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -204,7 +189,7 @@ if [ -z "$DECISION_FILE" ] && [ -z "$NO_BUTTONS_SET" ]; then case "$KIND" in answer|followup|final) if fm_ext_text_choice_shaped "$TEXT"; then - die "the text looks like a choice (numbered or coded options with a reply cue); post it with bin/fm-ext-ask.sh so the options become buttons, or pass --no-buttons \"\" to post a deliberate plain list" + die "the text looks like a choice (numbered or coded options with a cue or choice heading, or a direct digit-selection or reply-with-number cue); post it with bin/fm-ext-ask.sh so the options become buttons, or pass --no-buttons \"\" to post a deliberate plain list" fi ;; esac diff --git a/bin/fm-ext-lib.sh b/bin/fm-ext-lib.sh index e7ee2b5e8b9..eb378f7cabb 100644 --- a/bin/fm-ext-lib.sh +++ b/bin/fm-ext-lib.sh @@ -515,18 +515,31 @@ FM_EXT_DECISION_TEXT_MAX=1000 # Longest Discord component custom_id. # shellcheck disable=SC2034 # Read by the sourcing emit script. FM_EXT_DECISION_CUSTOM_ID_MAX=100 -# Choice-shaped text (bin/fm-ext-emit.sh header owns the rule): two or more -# option lines plus a reply/choice cue, or a "reply ... with the number/code" -# line on its own. +# Choice-shaped text (bin/fm-ext-emit.sh header owns the rule): a direct reply, +# pick, choose, select, or say cue with digits; two or more option lines plus +# a choice cue; or a choice heading followed by two or more option lines. FM_EXT_CHOICE_OPTION_RE='^[[:space:]]*((-|\*|•)[[:space:]]+)?(\(?[0-9]{1,2}[.):]|[A-Za-z][0-9]{1,2}[.):]?)([[:space:]]+[^[:space:]]|$)' FM_EXT_CHOICE_CUE_RE='(^|[^[:alnum:]_])((reply|respond|answer|type|send|pick|choose|select|tap|click)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,38}[^[:alnum:]_.!?])(number|code|letter|option|choice)s?|which (one|options?)|(pick|choose) one)([^[:alnum:]_]|$)' FM_EXT_CHOICE_REPLY_RE='(^|[^[:alnum:]_])reply([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,28}[^[:alnum:]_.!?])(with|using)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,18}[^[:alnum:]_.!?])(number|code|letter|option|choice)s?([^[:alnum:]_]|$)' +FM_EXT_CHOICE_DIGIT_CUE_RE='(^|[^[:alnum:]_])(reply|pick|choose|select|say)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,38}[^[:alnum:]_.!?])([0-9]{1,2}([[:space:]]*,[[:space:]]*[0-9]{1,2})?([[:space:]]*,?[[:space:]]+or[[:space:]]+[0-9]{1,2})?|[0-9]{1,2}[[:space:]]*/[[:space:]]*[0-9]{1,2}|[0-9]{1,2}[[:space:]]+or[[:space:]]+[0-9]{1,2})([^[:alnum:]_]|$)' +FM_EXT_CHOICE_HEADING_RE='^[[:space:]]*(\*\*(options?|your[[:space:]]+call|choose):?\*\*|__(options?|your[[:space:]]+call|choose):?__|(options?|your[[:space:]]+call|choose):?)[[:space:]]*$' fm_ext_text_choice_shaped() { - local text=$1 options - if printf '%s\n' "$text" | grep -qiE -e "$FM_EXT_CHOICE_REPLY_RE"; then + local text=$1 options line choice_heading=0 choice_options=0 + if printf '%s\n' "$text" | grep -qiE -e "$FM_EXT_CHOICE_REPLY_RE" \ + || printf '%s\n' "$text" | grep -qiE -e "$FM_EXT_CHOICE_DIGIT_CUE_RE"; then return 0 fi + while IFS= read -r line; do + if printf '%s\n' "$line" | grep -qiE -e "$FM_EXT_CHOICE_HEADING_RE"; then + choice_heading=1 + choice_options=0 + elif [ "$choice_heading" = 1 ] \ + && printf '%s\n' "$line" | grep -qE -e "$FM_EXT_CHOICE_OPTION_RE"; then + choice_options=$((choice_options + 1)) + [ "$choice_options" -ge 2 ] && return 0 + fi + done <<< "$text" options=$(printf '%s\n' "$text" | grep -cE -e "$FM_EXT_CHOICE_OPTION_RE") [ "${options:-0}" -ge 2 ] || return 1 printf '%s\n' "$text" | grep -qiE -e "$FM_EXT_CHOICE_CUE_RE" diff --git a/docs/configuration.md b/docs/configuration.md index 1ca43cae5c6..1541947fc5a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -978,7 +978,7 @@ Each decision post is bound to exactly one decision Firstmate already tracks, na `bin/fm-ext-ask.sh` is the one command that asks the captain a choice: it places or refreshes the captain hold for the task, writes the decision file, appends the numbered option lines to the post text in button order so text and buttons always match, and posts through `bin/fm-ext-emit.sh --decision-file` with the thread marked needs-you; its header owns the flags. Its `--mode done|release` is required and never inferred: `done` closes a question and `release` lifts the hold so held work proceeds. For a worker's existing keyed status decision, use `bin/fm-ext-emit.sh --decision-file` directly; its [header](../bin/fm-ext-emit.sh) owns the file format and validation. -The [emit header's choice guard](../bin/fm-ext-emit.sh) owns detection of plain-text choices and routes refused posts to `bin/fm-ext-ask.sh`. +The [emit header's choice guard](../bin/fm-ext-emit.sh) owns the matching rules, including digit-selection cues and standalone choice headings followed by at least two option lines, and routes refused plain-text choices to `bin/fm-ext-ask.sh`. `--no-buttons ""` posts a deliberate plain list anyway and records the reason in the outbox payload as `no_buttons_reason`; the emit header owns the exact detection rule. Each option button's `custom_id` carries the key and the option id, `fmd::`, and every decision post also carries a Discuss button, `fmd::d`. Channel members can read a `custom_id` through the Discord API, so the key is never written into a label or the post text, and emit refuses a post that names the key or its task id. diff --git a/tests/fm-ext-ask.test.sh b/tests/fm-ext-ask.test.sh index 262ca7507b5..51c64e92e9b 100755 --- a/tests/fm-ext-ask.test.sh +++ b/tests/fm-ext-ask.test.sh @@ -234,15 +234,22 @@ test_4_no_buttons_posts_a_deliberate_plain_list() { } test_5_ordinary_text_is_unaffected() { - local home=$TMP_ROOT/c5 + local home=$TMP_ROOT/c5 text generation=0 setup_home "$home" - emit_text "$home" 1 $'Two jobs are running.\n1. Ingest finished\n2. Eval running\nThe report lands tonight.' - [ "$RC" -eq 0 ] || fail "a numbered status list without a cue must post: $OUT" - emit_text "$home" 2 $'All 12 tests passed.\n5 files changed, 3 added.' - [ "$RC" -eq 0 ] || fail "a plain answer must post: $OUT" + for text in \ + $'Two jobs are running.\n1. Ingest finished\n2. Eval running\nThe report lands tonight.' \ + 'All 12 tests passed. 5 files changed, 3 added.' \ + 'PR 562 merged; 3 of 5 cases pass' \ + 'steps 1, 2 and 3 are done' \ + $'Options:\n1. Only one possible route.' \ + $'1. First finding\n2. Second finding'; do + generation=$((generation + 1)) + emit_text "$home" "$generation" "$text" + [ "$RC" -eq 0 ] || fail "ordinary text triggered the choice guard: $text: $OUT" + done jq -e 'has("no_buttons_reason") | not' "$(payload_of "$home" answer 1)" >/dev/null \ || fail "a normal post must not carry a no-buttons reason" - pass "5 a numbered list without a choice cue and plain prose post unchanged" + pass "5 status prose and numbered lists without a choice cue or heading post unchanged" } test_6_choice_with_a_decision_file_is_unaffected() { @@ -382,6 +389,32 @@ test_9_plural_choice_cues_require_buttons() { pass "9 plural options, choices, numbers, codes, and letters require buttons for every guarded post kind" } +test_10_digit_choice_cues_and_headings_require_buttons() { + local home=$TMP_ROOT/c10 text generation=0 + setup_home "$home" + for text in \ + $'Options:\n1. **Restart only the eval service on today\'s production build** ... (Recommended)\n2. Report only the search-only retrieval scores we already have ...\n3. Wait until tomorrow.\n\nReply 1, 2 or 3.' \ + $'Options:\n1. Keep them here (recommended, already running).\n2. Keep them here, but run their heavier live tests ...\n3. Fix the Boat second mate first ...\n\nReply 2 or 3 if you want either. 1 needs nothing.' \ + $'**Your call:**\n1. Restart the service.\n2. Wait until tomorrow.\n3. Skip this change.' \ + $'1. North\n2. South\nReply 1, 2 or 3.' \ + $'1. North\n2. South\nPick 2 or 3.' \ + $'1. North\n2. South\nChoose 1/2.' \ + $'1. North\n2. South\nSelect 1 or 2.' \ + $'1. North\n2. South\nSay 1 or 2.' \ + 'Reply 1 or 2.'; do + generation=$((generation + 1)) + emit_text "$home" "$generation" "$text" + [ "$RC" -ne 0 ] || fail "choice escaped without buttons: $text" + assert_contains "$OUT" "fm-ext-ask.sh" "choice refusal must name ask" + emit_text "$home" "$generation" "$text" --no-buttons "deliberate plain choice list" + [ "$RC" -eq 0 ] || fail "--no-buttons did not allow a deliberate plain list: $OUT" + jq -e '.no_buttons_reason == "deliberate plain choice list"' \ + "$(payload_of "$home" answer "$generation")" >/dev/null \ + || fail "--no-buttons reason was not recorded for: $text" + done + pass "10 digit choice cues and choice headings require buttons unless --no-buttons is explicit" +} + test_1_ask_holds_and_posts_buttons_in_text_order test_2_mode_is_required_and_pre_hold_refusals test_3_emit_refuses_a_choice_without_buttons @@ -391,3 +424,4 @@ test_6_choice_with_a_decision_file_is_unaffected test_7_settled_ask_retries_preserve_state test_8_choice_cues_match_whole_words test_9_plural_choice_cues_require_buttons +test_10_digit_choice_cues_and_headings_require_buttons From 5b6d9e0799b305dbf8002367e5c67719eace0628 Mon Sep 17 00:00:00 2001 From: dnth Date: Thu, 15 Oct 2026 22:54:12 +0800 Subject: [PATCH 2/6] fix(ext): require digit enumerations Do not treat one-number status prose as a choice cue. --- bin/fm-ext-lib.sh | 2 +- tests/fm-ext-ask.test.sh | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/fm-ext-lib.sh b/bin/fm-ext-lib.sh index eb378f7cabb..f9320c5138e 100644 --- a/bin/fm-ext-lib.sh +++ b/bin/fm-ext-lib.sh @@ -521,7 +521,7 @@ FM_EXT_DECISION_CUSTOM_ID_MAX=100 FM_EXT_CHOICE_OPTION_RE='^[[:space:]]*((-|\*|•)[[:space:]]+)?(\(?[0-9]{1,2}[.):]|[A-Za-z][0-9]{1,2}[.):]?)([[:space:]]+[^[:space:]]|$)' FM_EXT_CHOICE_CUE_RE='(^|[^[:alnum:]_])((reply|respond|answer|type|send|pick|choose|select|tap|click)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,38}[^[:alnum:]_.!?])(number|code|letter|option|choice)s?|which (one|options?)|(pick|choose) one)([^[:alnum:]_]|$)' FM_EXT_CHOICE_REPLY_RE='(^|[^[:alnum:]_])reply([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,28}[^[:alnum:]_.!?])(with|using)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,18}[^[:alnum:]_.!?])(number|code|letter|option|choice)s?([^[:alnum:]_]|$)' -FM_EXT_CHOICE_DIGIT_CUE_RE='(^|[^[:alnum:]_])(reply|pick|choose|select|say)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,38}[^[:alnum:]_.!?])([0-9]{1,2}([[:space:]]*,[[:space:]]*[0-9]{1,2})?([[:space:]]*,?[[:space:]]+or[[:space:]]+[0-9]{1,2})?|[0-9]{1,2}[[:space:]]*/[[:space:]]*[0-9]{1,2}|[0-9]{1,2}[[:space:]]+or[[:space:]]+[0-9]{1,2})([^[:alnum:]_]|$)' +FM_EXT_CHOICE_DIGIT_CUE_RE='(^|[^[:alnum:]_])(reply|pick|choose|select|say)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,38}[^[:alnum:]_.!?])(([0-9]{1,2}([[:space:]]*,[[:space:]]*[0-9]{1,2}([[:space:]]*,?[[:space:]]+or[[:space:]]+[0-9]{1,2})?|[[:space:]]+or[[:space:]]+[0-9]{1,2}))|[0-9]{1,2}[[:space:]]*/[[:space:]]*[0-9]{1,2})([^[:alnum:]_]|$)' FM_EXT_CHOICE_HEADING_RE='^[[:space:]]*(\*\*(options?|your[[:space:]]+call|choose):?\*\*|__(options?|your[[:space:]]+call|choose):?__|(options?|your[[:space:]]+call|choose):?)[[:space:]]*$' fm_ext_text_choice_shaped() { diff --git a/tests/fm-ext-ask.test.sh b/tests/fm-ext-ask.test.sh index 51c64e92e9b..261a0a92098 100755 --- a/tests/fm-ext-ask.test.sh +++ b/tests/fm-ext-ask.test.sh @@ -239,6 +239,7 @@ test_5_ordinary_text_is_unaffected() { for text in \ $'Two jobs are running.\n1. Ingest finished\n2. Eval running\nThe report lands tonight.' \ 'All 12 tests passed. 5 files changed, 3 added.' \ + 'Say all 3 tests passed' \ 'PR 562 merged; 3 of 5 cases pass' \ 'steps 1, 2 and 3 are done' \ $'Options:\n1. Only one possible route.' \ From cb2b35461073f9feabc9cbc388d9b1bf755abe2a Mon Sep 17 00:00:00 2001 From: dnth Date: Thu, 15 Oct 2026 23:01:35 +0800 Subject: [PATCH 3/6] no-mistakes(document): Clarify choice guard matching rules in emitter help --- bin/fm-ext-emit.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/fm-ext-emit.sh b/bin/fm-ext-emit.sh index ca3ba847160..bc49d6b6957 100755 --- a/bin/fm-ext-emit.sh +++ b/bin/fm-ext-emit.sh @@ -55,11 +55,13 @@ # bin/fm-ext-lib.sh), so the post can only ever answer that lifecycle. # # CHOICE GUARD. An answer, followup, or final whose text has the shape of a choice is refused unless --decision-file is given, so a choice cannot go out as plain text by mistake. -# Detection is case-insensitive and uses these rules: +# Detection is case-insensitive, cues match within one line, and it uses these rules: # option line - a line, after optional indent and a "-", "*", or bullet marker, that starts with a 1-2 digit number ended by ".", ")", or ":" and optionally prefixed by "(" ("1." / "2)" / "(3)"), or a code of one letter plus 1-2 digits with an optional ".", ")", or ":" ("A1." / "B2:" / "B2 text"); the token must be followed by whitespace and non-whitespace text, or immediately by the end of the line. # choice cue - "reply/respond/answer/type/send/pick/choose/select/tap/click", 1-40 intervening characters, then "number/code/letter/option/choice" (singular or plural), without crossing ".", "!", or "?", or "which one", "which option(s)", "pick one", or "choose one"; cue words match whole words. +# Letters, digits, and underscore are word characters; intervening spans start and end with non-word characters, and fixed phrases use single spaces. # digit cue - a reply, pick, choose, select, or say verb followed within 1-40 characters by at least two 1-2 digit numbers joined by commas, "/", or "or" (for example "1, 2 or 3", "2 or 3", "1/2", or "1 or 2"). -# choice heading - a standalone "Options", "Your call", or "Choose" line, with an optional colon and Markdown bold; two or more option lines must follow it. +# choice heading - a standalone "Option", "Options", "Your call", or "Choose" line, with an optional colon and optional Markdown bold ("**" or "__" around the whole heading); two or more option lines must follow it. +# Digit cues use the same word boundaries and intervening-span punctuation restrictions as choice cues. # Two or more option lines plus a choice cue are refused, as is a digit cue by itself. # A standalone reply cue also suffices: "reply", 1-30 intervening characters, "with/using", 1-20 intervening characters, then "number/code/letter/option/choice" (singular or plural), with the same word boundaries and punctuation restrictions. # Numbered lists without a choice cue post normally, except when a choice heading is followed by two or more option lines. From 78b8bfb957bf94663f52b651c6eb4d4a7be7ffb4 Mon Sep 17 00:00:00 2001 From: dnth Date: Fri, 16 Oct 2026 08:42:42 +0800 Subject: [PATCH 4/6] docs(ext): clarify singular option heading Document both standalone Option and Options headings in the emit contract, matching the approved choice-header forms. --- bin/fm-ext-emit.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/fm-ext-emit.sh b/bin/fm-ext-emit.sh index bc49d6b6957..02727073ef6 100755 --- a/bin/fm-ext-emit.sh +++ b/bin/fm-ext-emit.sh @@ -60,7 +60,7 @@ # choice cue - "reply/respond/answer/type/send/pick/choose/select/tap/click", 1-40 intervening characters, then "number/code/letter/option/choice" (singular or plural), without crossing ".", "!", or "?", or "which one", "which option(s)", "pick one", or "choose one"; cue words match whole words. # Letters, digits, and underscore are word characters; intervening spans start and end with non-word characters, and fixed phrases use single spaces. # digit cue - a reply, pick, choose, select, or say verb followed within 1-40 characters by at least two 1-2 digit numbers joined by commas, "/", or "or" (for example "1, 2 or 3", "2 or 3", "1/2", or "1 or 2"). -# choice heading - a standalone "Option", "Options", "Your call", or "Choose" line, with an optional colon and optional Markdown bold ("**" or "__" around the whole heading); two or more option lines must follow it. +# choice heading - a standalone "Option" or "Options" heading, "Your call" heading, or "Choose" heading, optionally followed by a colon and optionally wrapped in Markdown bold ("**" or "__"); two or more option lines must follow it. # Digit cues use the same word boundaries and intervening-span punctuation restrictions as choice cues. # Two or more option lines plus a choice cue are refused, as is a digit cue by itself. # A standalone reply cue also suffices: "reply", 1-30 intervening characters, "with/using", 1-20 intervening characters, then "number/code/letter/option/choice" (singular or plural), with the same word boundaries and punctuation restrictions. From e582f8724d1ce9276443708a68d35da4e47db291 Mon Sep 17 00:00:00 2001 From: dnth Date: Fri, 16 Oct 2026 08:52:36 +0800 Subject: [PATCH 5/6] no-mistakes(review): Fix choice headings with colons outside bold markers --- bin/fm-ext-lib.sh | 2 +- tests/fm-ext-ask.test.sh | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/bin/fm-ext-lib.sh b/bin/fm-ext-lib.sh index f9320c5138e..0cc240bd132 100644 --- a/bin/fm-ext-lib.sh +++ b/bin/fm-ext-lib.sh @@ -522,7 +522,7 @@ FM_EXT_CHOICE_OPTION_RE='^[[:space:]]*((-|\*|•)[[:space:]]+)?(\(?[0-9]{1,2}[.) FM_EXT_CHOICE_CUE_RE='(^|[^[:alnum:]_])((reply|respond|answer|type|send|pick|choose|select|tap|click)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,38}[^[:alnum:]_.!?])(number|code|letter|option|choice)s?|which (one|options?)|(pick|choose) one)([^[:alnum:]_]|$)' FM_EXT_CHOICE_REPLY_RE='(^|[^[:alnum:]_])reply([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,28}[^[:alnum:]_.!?])(with|using)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,18}[^[:alnum:]_.!?])(number|code|letter|option|choice)s?([^[:alnum:]_]|$)' FM_EXT_CHOICE_DIGIT_CUE_RE='(^|[^[:alnum:]_])(reply|pick|choose|select|say)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,38}[^[:alnum:]_.!?])(([0-9]{1,2}([[:space:]]*,[[:space:]]*[0-9]{1,2}([[:space:]]*,?[[:space:]]+or[[:space:]]+[0-9]{1,2})?|[[:space:]]+or[[:space:]]+[0-9]{1,2}))|[0-9]{1,2}[[:space:]]*/[[:space:]]*[0-9]{1,2})([^[:alnum:]_]|$)' -FM_EXT_CHOICE_HEADING_RE='^[[:space:]]*(\*\*(options?|your[[:space:]]+call|choose):?\*\*|__(options?|your[[:space:]]+call|choose):?__|(options?|your[[:space:]]+call|choose):?)[[:space:]]*$' +FM_EXT_CHOICE_HEADING_RE='^[[:space:]]*(\*\*(options?|your[[:space:]]+call|choose)(:?\*\*|\*\*:)|__(options?|your[[:space:]]+call|choose)(:?__|__:)|(options?|your[[:space:]]+call|choose):?)[[:space:]]*$' fm_ext_text_choice_shaped() { local text=$1 options line choice_heading=0 choice_options=0 diff --git a/tests/fm-ext-ask.test.sh b/tests/fm-ext-ask.test.sh index 261a0a92098..8bc5202bc5d 100755 --- a/tests/fm-ext-ask.test.sh +++ b/tests/fm-ext-ask.test.sh @@ -416,6 +416,38 @@ test_10_digit_choice_cues_and_headings_require_buttons() { pass "10 digit choice cues and choice headings require buttons unless --no-buttons is explicit" } +test_11_bold_headings_allow_colons_inside_or_outside() { + local home=$TMP_ROOT/c11 kind heading marker title text out rc generation=0 + setup_home "$home" + for kind in answer followup final; do + for heading in Option Options 'Your call' Choose; do + for marker in '**' '__'; do + for title in "$marker$heading:$marker" "$marker$heading$marker:" "$marker$heading$marker"; do + generation=$((generation + 1)) + text=$(printf '%s\n1. North\n2. South' "$title") + printf '%s' "$text" > "$home/post.txt" + out=$(home_env "$home" "$EMIT" --request-id "$RID" --kind "$kind" --generation "$generation" \ + --text-file "$home/post.txt" 2>&1) + rc=$? + [ "$rc" -ne 0 ] || fail "$kind choice bypassed the guard: $title" + assert_contains "$out" "fm-ext-ask.sh" "heading refusal must name ask" + if compgen -G "$home/state/ext-outbox/*.$kind.$generation.json" >/dev/null; then + fail "refused heading choice published a payload" + fi + out=$(home_env "$home" "$EMIT" --request-id "$RID" --kind "$kind" --generation "$generation" \ + --text-file "$home/post.txt" --no-buttons 'deliberate plain choice list' 2>&1) + rc=$? + [ "$rc" -eq 0 ] || fail "--no-buttons refused $title: $out" + jq -e '.no_buttons_reason == "deliberate plain choice list"' \ + "$(payload_of "$home" "$kind" "$generation")" >/dev/null \ + || fail "heading escape reason was not recorded" + done + done + done + done + pass "11 bold choice headings require buttons with colons inside, outside, or omitted" +} + test_1_ask_holds_and_posts_buttons_in_text_order test_2_mode_is_required_and_pre_hold_refusals test_3_emit_refuses_a_choice_without_buttons @@ -426,3 +458,4 @@ test_7_settled_ask_retries_preserve_state test_8_choice_cues_match_whole_words test_9_plural_choice_cues_require_buttons test_10_digit_choice_cues_and_headings_require_buttons +test_11_bold_headings_allow_colons_inside_or_outside From 8f50825790bdc842d0b42ab33c4721ee0d10a590 Mon Sep 17 00:00:00 2001 From: dnth Date: Fri, 16 Oct 2026 08:57:31 +0800 Subject: [PATCH 6/6] no-mistakes(document): Clarify choice-heading documentation and remove duplicate rules --- bin/fm-ext-emit.sh | 2 +- bin/fm-ext-lib.sh | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/bin/fm-ext-emit.sh b/bin/fm-ext-emit.sh index 02727073ef6..a74463a22ec 100755 --- a/bin/fm-ext-emit.sh +++ b/bin/fm-ext-emit.sh @@ -60,7 +60,7 @@ # choice cue - "reply/respond/answer/type/send/pick/choose/select/tap/click", 1-40 intervening characters, then "number/code/letter/option/choice" (singular or plural), without crossing ".", "!", or "?", or "which one", "which option(s)", "pick one", or "choose one"; cue words match whole words. # Letters, digits, and underscore are word characters; intervening spans start and end with non-word characters, and fixed phrases use single spaces. # digit cue - a reply, pick, choose, select, or say verb followed within 1-40 characters by at least two 1-2 digit numbers joined by commas, "/", or "or" (for example "1, 2 or 3", "2 or 3", "1/2", or "1 or 2"). -# choice heading - a standalone "Option" or "Options" heading, "Your call" heading, or "Choose" heading, optionally followed by a colon and optionally wrapped in Markdown bold ("**" or "__"); two or more option lines must follow it. +# choice heading - a standalone "Option" or "Options" heading, "Your call" heading, or "Choose" heading, optionally wrapped in Markdown bold ("**" or "__") and optionally followed by a colon inside or outside the closing bold marker; two or more option lines must follow it. # Digit cues use the same word boundaries and intervening-span punctuation restrictions as choice cues. # Two or more option lines plus a choice cue are refused, as is a digit cue by itself. # A standalone reply cue also suffices: "reply", 1-30 intervening characters, "with/using", 1-20 intervening characters, then "number/code/letter/option/choice" (singular or plural), with the same word boundaries and punctuation restrictions. diff --git a/bin/fm-ext-lib.sh b/bin/fm-ext-lib.sh index 0cc240bd132..f964aae02d2 100644 --- a/bin/fm-ext-lib.sh +++ b/bin/fm-ext-lib.sh @@ -515,9 +515,7 @@ FM_EXT_DECISION_TEXT_MAX=1000 # Longest Discord component custom_id. # shellcheck disable=SC2034 # Read by the sourcing emit script. FM_EXT_DECISION_CUSTOM_ID_MAX=100 -# Choice-shaped text (bin/fm-ext-emit.sh header owns the rule): a direct reply, -# pick, choose, select, or say cue with digits; two or more option lines plus -# a choice cue; or a choice heading followed by two or more option lines. +# The bin/fm-ext-emit.sh header owns the choice-shaped text detection rules. FM_EXT_CHOICE_OPTION_RE='^[[:space:]]*((-|\*|•)[[:space:]]+)?(\(?[0-9]{1,2}[.):]|[A-Za-z][0-9]{1,2}[.):]?)([[:space:]]+[^[:space:]]|$)' FM_EXT_CHOICE_CUE_RE='(^|[^[:alnum:]_])((reply|respond|answer|type|send|pick|choose|select|tap|click)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,38}[^[:alnum:]_.!?])(number|code|letter|option|choice)s?|which (one|options?)|(pick|choose) one)([^[:alnum:]_]|$)' FM_EXT_CHOICE_REPLY_RE='(^|[^[:alnum:]_])reply([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,28}[^[:alnum:]_.!?])(with|using)([^[:alnum:]_.!?]|[^[:alnum:]_.!?][^.!?]{0,18}[^[:alnum:]_.!?])(number|code|letter|option|choice)s?([^[:alnum:]_]|$)'