Skip to content

fix: catch more Discord choices that require buttons - #252

Merged
dnth merged 6 commits into
mainfrom
fm/fm-ext-choice-guard-digits
Oct 16, 2026
Merged

dnth merged 6 commits into
mainfrom
fm/fm-ext-choice-guard-digits

Conversation

@dnth

@dnth dnth commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Intent

Prevent Discord choice posts from going out as plain text without buttons by widening the choice guard used by bin/fm-ext-emit.sh. Add reply, pick, choose, select, or say cues followed within a short span by an enumeration of at least two digits, including 1, 2 or 3; 2 or 3; 1/2; and 1 or 2. Treat standalone Option, Options, Your call, or Choose headings, optionally bold and with or without a colon, followed by two or more option lines as choice-shaped; the approved header must explicitly name singular Option. Keep ordinary numbered status lists without a choice cue or heading posting normally, and preserve --no-buttons as the explicit plain-list escape. Avoid false positives on “PR 562 merged; 3 of 5 cases pass”, “steps 1, 2 and 3 are done”, and unheaded, uncued numbered findings. Refuse these choice examples: (a) “Options: 1. Restart only the eval service on today's production build (Recommended); 2. Report only the search-only retrieval scores already available; 3. Wait until tomorrow. Reply 1, 2 or 3.” (b) “Options: 1. Keep them here (recommended, already running); 2. Keep them here, but run their heavier live tests; 3. Fix the Boat second mate first. Reply 2 or 3 if you want either. 1 needs nothing.” (c) a bold “Your call:” heading followed by numbered options without a cue. Update the emit header/help and docs/configuration.md where it documents the guard, with behavioral tests for the refusal, escape, and false-positive cases. Preserve existing PR commit SHAs during any base update; use the No-Mistakes pipeline and do not force-push outside it or rewrite commits already on the PR. CI approval requires every check green with no cancellations; if the known fm-watch-triage remote-reply failure appears in CI, stop at a needs-decision with exact evidence.

What Changed

  • Refuse plain-text Discord choices with digit-selection cues or standalone Option, Options, Your call, or Choose headings followed by at least two option lines, including bold headings.
  • Update the emit header, refusal message, and configuration docs to describe the expanded guard.
  • Add regression coverage for choice refusals, the --no-buttons escape, and ordinary status prose and numbered findings.

Risk Assessment

✅ Low: The changes are bounded to choice detection, satisfy the accepted matching requirements, and preserve existing escape and delivery paths.

Testing

The focused behavioral test file and all 185 independent live CLI cases passed. Captured refusal messages, accepted payloads, and public help. No screenshot applies because this change affects a CLI publication guard. Disposable data was removed.

  • Live validation: ✅ go - 5 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Emit required examples and digit selections across answer, followup, and final; refuse without publishing payloads ✅ pass live choice-cli-transcript.json: required examples and direct digit cues
Emit Option, Options, Your call, and Choose headings with two options; refuse plain and bold forms with colons inside, outside, or omitted ✅ pass live choice-cli-transcript.json: headings
Pass --no-buttons with a reason; publish unchanged choice text and persist the reason without buttons ✅ pass live choice-cli-transcript.json: explicit escape
Emit ordinary status lists, numbered findings, PR statistics, and boundary near-misses; publish unchanged ✅ pass live choice-cli-transcript.json: false positives and boundaries
Emit a choice-shaped acknowledgement; preserve the acknowledgement exemption ✅ pass live choice-cli-transcript.json: ack bypass
Evidence: Live CLI inputs, refusal messages, and persisted payloads
[
  {
    "group": "setup",
    "command": "fm-ext-intake.sh --text-file - --no-wake (isolated FM_HOME)",
    "stdout": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22\n",
    "stderr": "",
    "rc": 0
  },
  {
    "group": "required examples",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 1 --text-file -",
    "input": "Options: 1. Restart only the eval service on today's production build (Recommended); 2. Report only the search-only retrieval scores already available; 3. Wait until tomorrow. Reply 1, 2 or 3.",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "required examples",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 2 --text-file -",
    "input": "Options: 1. Keep them here (recommended, already running); 2. Keep them here, but run their heavier live tests; 3. Fix the Boat second mate first. Reply 2 or 3 if you want either. 1 needs nothing.",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "required examples",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 3 --text-file -",
    "input": "**Your call:**\n1. North\n2. South",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "direct digit cues",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 4 --text-file -",
    "input": "Reply 1, 2 or 3.",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "direct digit cues",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 5 --text-file -",
    "input": "Reply 2 or 3.",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "direct digit cues",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 6 --text-file -",
    "input": "Reply 1/2.",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "direct digit cues",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 7 --text-file -",
    "input": "Reply 1 or 2.",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "direct digit cues",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 8 --text-file -",
    "input": "Pick 1, 2 or 3.",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "direct digit cues",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 9 --text-file -",
    "input": "Pick 2 or 3.",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "direct digit cues",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 10 --text-file -",
    "input": "Pick 1/2.",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "direct digit cues",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 11 --text-file -",
    "input": "Pick 1 or 2.",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "direct digit cues",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 12 --text-file -",
    "input": "Choose 1, 2 or 3.",
    "rc": 2,
    "stdout": "",
    "stderr": "fm-ext-emit: 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\n",
    "payload": null,
    "pass_": true
  },
  {
    "group": "direct digit cues",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answ

... [109398 bytes truncated] ...

12083
    },
    "pass_": true
  },
  {
    "group": "false positives and boundaries",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 178 --text-file -",
    "input": "Say all 3 tests passed",
    "rc": 0,
    "stdout": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22\n",
    "stderr": "",
    "payload": {
      "schema_version": 1,
      "request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444",
      "slug": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22",
      "kind": "answer",
      "generation": 178,
      "platform": "discord",
      "source": "hermes-gateway",
      "guild_id": "111111111111111111",
      "channel_id": "222222222222222222",
      "thread_id": "222222222222222222",
      "message_id": "444444444444444444",
      "origin": "slash",
      "text": "Say all 3 tests passed",
      "recorded_at": 1792112083
    },
    "pass_": true
  },
  {
    "group": "false positives and boundaries",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 179 --text-file -",
    "input": "Preply 1 or 2.",
    "rc": 0,
    "stdout": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22\n",
    "stderr": "",
    "payload": {
      "schema_version": 1,
      "request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444",
      "slug": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22",
      "kind": "answer",
      "generation": 179,
      "platform": "discord",
      "source": "hermes-gateway",
      "guild_id": "111111111111111111",
      "channel_id": "222222222222222222",
      "thread_id": "222222222222222222",
      "message_id": "444444444444444444",
      "origin": "slash",
      "text": "Preply 1 or 2.",
      "recorded_at": 1792112083
    },
    "pass_": true
  },
  {
    "group": "false positives and boundaries",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 180 --text-file -",
    "input": "Reply 562 or 3.",
    "rc": 0,
    "stdout": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22\n",
    "stderr": "",
    "payload": {
      "schema_version": 1,
      "request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444",
      "slug": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22",
      "kind": "answer",
      "generation": 180,
      "platform": "discord",
      "source": "hermes-gateway",
      "guild_id": "111111111111111111",
      "channel_id": "222222222222222222",
      "thread_id": "222222222222222222",
      "message_id": "444444444444444444",
      "origin": "slash",
      "text": "Reply 562 or 3.",
      "recorded_at": 1792112084
    },
    "pass_": true
  },
  {
    "group": "false positives and boundaries",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 181 --text-file -",
    "input": "Reply. 1 or 2.",
    "rc": 0,
    "stdout": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22\n",
    "stderr": "",
    "payload": {
      "schema_version": 1,
      "request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444",
      "slug": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22",
      "kind": "answer",
      "generation": 181,
      "platform": "discord",
      "source": "hermes-gateway",
      "guild_id": "111111111111111111",
      "channel_id": "222222222222222222",
      "thread_id": "222222222222222222",
      "message_id": "444444444444444444",
      "origin": "slash",
      "text": "Reply. 1 or 2.",
      "recorded_at": 1792112084
    },
    "pass_": true
  },
  {
    "group": "false positives and boundaries",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 182 --text-file -",
    "input": "Reply\n1 or 2.",
    "rc": 0,
    "stdout": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22\n",
    "stderr": "",
    "payload": {
      "schema_version": 1,
      "request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444",
      "slug": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22",
      "kind": "answer",
      "generation": 182,
      "platform": "discord",
      "source": "hermes-gateway",
      "guild_id": "111111111111111111",
      "channel_id": "222222222222222222",
      "thread_id": "222222222222222222",
      "message_id": "444444444444444444",
      "origin": "slash",
      "text": "Reply\n1 or 2.",
      "recorded_at": 1792112084
    },
    "pass_": true
  },
  {
    "group": "false positives and boundaries",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 183 --text-file -",
    "input": "Reply xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 1 or 2.",
    "rc": 0,
    "stdout": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22\n",
    "stderr": "",
    "payload": {
      "schema_version": 1,
      "request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444",
      "slug": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22",
      "kind": "answer",
      "generation": 183,
      "platform": "discord",
      "source": "hermes-gateway",
      "guild_id": "111111111111111111",
      "channel_id": "222222222222222222",
      "thread_id": "222222222222222222",
      "message_id": "444444444444444444",
      "origin": "slash",
      "text": "Reply xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 1 or 2.",
      "recorded_at": 1792112085
    },
    "pass_": true
  },
  {
    "group": "false positives and boundaries",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind answer --generation 184 --text-file -",
    "input": "Options available:\n1. Build passed\n2. Tests passed",
    "rc": 0,
    "stdout": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22\n",
    "stderr": "",
    "payload": {
      "schema_version": 1,
      "request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444",
      "slug": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22",
      "kind": "answer",
      "generation": 184,
      "platform": "discord",
      "source": "hermes-gateway",
      "guild_id": "111111111111111111",
      "channel_id": "222222222222222222",
      "thread_id": "222222222222222222",
      "message_id": "444444444444444444",
      "origin": "slash",
      "text": "Options available:\n1. Build passed\n2. Tests passed",
      "recorded_at": 1792112085
    },
    "pass_": true
  },
  {
    "group": "ack bypass",
    "command": "fm-ext-emit.sh --request-id discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444 --kind ack --generation 185 --text-file -",
    "input": "Reply 1 or 2.",
    "rc": 0,
    "stdout": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22\n",
    "stderr": "",
    "payload": {
      "schema_version": 1,
      "request_id": "discord:111111111111111111:222222222222222222:222222222222222222:444444444444444444",
      "slug": "2fdeeeec40eba18cfc9a36b53885b48edcf03e3c024a8dba42c9c42a6eb0dc22",
      "kind": "ack",
      "generation": 185,
      "platform": "discord",
      "source": "hermes-gateway",
      "guild_id": "111111111111111111",
      "channel_id": "222222222222222222",
      "thread_id": "222222222222222222",
      "message_id": "444444444444444444",
      "origin": "slash",
      "text": "Reply 1 or 2.",
      "recorded_at": 1792112085
    },
    "pass_": true
  }
]
Evidence: Public emit help
Emit one Communication Officer outbox payload (ack/answer/followup/final).

Usage:
  fm-ext-emit.sh --request-id discord:<guild>:<channel>:<thread>:<message>
    --kind ack|answer|followup|final --generation <n>
    (--text-file <path> | --text-file -) [--decision-file <path>]
    [--no-buttons "<reason>"]
    [--thread-title <text>] [--thread-state needs-you|done|none]

To ask the captain a choice, use bin/fm-ext-ask.sh: it holds the call, writes
the decision file, numbers the options in the text, and posts through this
script, so text and buttons always match.

--thread-title names the request's topic thread ("<Project> · <topic>", one
line, at most 60 characters, screened like reply text) on the reply that
creates it; once the thread exists, later titles are recorded but do not
replace its name. Over-long, multi-line, or unsafe titles are
refused here instead of silently cut. --thread-state renames the existing
topic thread: needs-you prefixes "❓ ", done prefixes "✅ ", none removes the
prefix. docs/configuration.md "Topic threads" owns rename eligibility,
best-effort delivery, auto-clear, and retry behaviour.

Writes state/ext-outbox/<slug>.<kind>.<generation>.json once. Re-emitting the
same identity is a no-op success. A posting marker without a receipt is
refused (mid-delivery), matching public-follow-up delivery-posting behavior.
Destination fields come from the durable per-request context, so a delayed
follow-up still works after the inbox file is gone. The payload also carries
the request's origin (slash, message, button, or open for a thread
bin/fm-ext-open.sh opened; slash when the context predates it), which tells
the gateway poster whether it can react to the original message.

--decision-file attaches option buttons to an answer, followup, or final.
The file is JSON:
  {"key": "hold:<task-id>" | "key:<task-id>:<slug>",
   "mode": "done" | "release",
   "options": [{"label": "<text>", "confirm": true|false, "recommended": true|false}, ...]}
with 1-5 options whose input and displayed labels are distinct, 1-80 characters, and free of
control characters, in the same order as the post text numbers them; option
ids are 1-based in that order. "mode" (captain calls only, default done)
chooses how a chosen option closes the call, as in fm-captain-hold.sh
answers. "confirm" and "recommended" are optional booleans, default false.
At most one option may set "recommended": true; emit adds "⭐ " unless the
label already starts with ⭐, and stores the displayed label in the payload.
The added prefix counts toward the 80-character label limit.
The key must name a decision that is open right now (a held captain
call, or a worker's open keyed status decision) and must fit Discord's
100-character custom_id as fmd:<key>:<n>. An option needs a confirmation
click when it declares confirm, and always when its label matches
FM_EXT_DECISION_CONFIRM_PATTERN in bin/fm-ext-lib.sh. The post text and labels
are refused when they name the key or its task id, because only the button
custom_ids may carry the key. The payload's decision field carries the key,
mode, numbered options, the post text cut to its first 1000 characters, and
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 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 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.
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.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • 🚨 bin/fm-ext-lib.sh:525 - Intent requires headings “optionally bold and with or without a colon,” but the new alternatives ...:?\*\* and ...:?__ only accept a colon inside bold markup. For **Your call**:\n1. North\n2. South, neither cue matches, the heading is rejected, and emit publishes plain text without buttons. The same omission affects Option, Options, Your call, and Choose in both bold representations at bin/fm-ext-lib.sh:525. Accept the colon outside closing bold markup as well.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 5 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Emit required examples and digit selections across answer, followup, and final; refuse without publishing payloads ✅ pass live choice-cli-transcript.json: required examples and direct digit cues
Emit Option, Options, Your call, and Choose headings with two options; refuse plain and bold forms with colons inside, outside, or omitted ✅ pass live choice-cli-transcript.json: headings
Pass --no-buttons with a reason; publish unchanged choice text and persist the reason without buttons ✅ pass live choice-cli-transcript.json: explicit escape
Emit ordinary status lists, numbered findings, PR statistics, and boundary near-misses; publish unchanged ✅ pass live choice-cli-transcript.json: false positives and boundaries
Emit a choice-shaped acknowledgement; preserve the acknowledgement exemption ✅ pass live choice-cli-transcript.json: ack bypass
  • TMPDIR="$PWD/.test-choice-tmp" bash tests/fm-ext-ask.test.sh
  • python3 ~/.no-mistakes/evidence/01M512RJNBDT4XKAZPC8XMS6GY/drive-choice.py — 185 cases through real intake and emit commands in a disposable FM_HOME
  • bash bin/fm-ext-emit.sh --help
  • Removed disposable test homes and verified the working tree was clean.
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

dnth added 6 commits October 16, 2026 08:46
Require buttons for numeric choice cues and labeled option lists.
Do not treat one-number status prose as a choice cue.
Document both standalone Option and Options headings in the emit contract, matching the approved choice-header forms.
@dnth
dnth force-pushed the fm/fm-ext-choice-guard-digits branch from 58403b9 to 8f50825 Compare October 16, 2026 01:27
@dnth
dnth merged commit b812b05 into main Oct 16, 2026
18 checks passed
@dnth
dnth deleted the fm/fm-ext-choice-guard-digits branch October 16, 2026 07:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant