Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 16 additions & 29 deletions bin/fm-ext-emit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<reason>" 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 "<reason>" 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)"
Expand Down Expand Up @@ -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 \"<reason>\" 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 \"<reason>\" to post a deliberate plain list"
fi
;;
esac
Expand Down
21 changes: 16 additions & 5 deletions bin/fm-ext-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<reason>"` 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:<key>:<n>`, and every decision post also carries a Discuss button, `fmd:<key>: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.
Expand Down
80 changes: 74 additions & 6 deletions tests/fm-ext-ask.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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
Loading