diff --git a/bin/fm-ext-emit.sh b/bin/fm-ext-emit.sh index 4b36f425622..a74463a22ec 100755 --- a/bin/fm-ext-emit.sh +++ b/bin/fm-ext-emit.sh @@ -54,35 +54,22 @@ # 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, 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 "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. +# 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 +191,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..f964aae02d2 100644 --- a/bin/fm-ext-lib.sh +++ b/bin/fm-ext-lib.sh @@ -515,18 +515,29 @@ 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. +# 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:]_]|$)' +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() { - 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..8bc5202bc5d 100755 --- a/tests/fm-ext-ask.test.sh +++ b/tests/fm-ext-ask.test.sh @@ -234,15 +234,23 @@ 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.' \ + '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.' \ + $'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 +390,64 @@ 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_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 @@ -391,3 +457,5 @@ 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 +test_11_bold_headings_allow_colons_inside_or_outside