From 25ee5d610339512fae6f8d5f05927deff57ab7ab Mon Sep 17 00:00:00 2001 From: Joseph Kim Date: Mon, 17 Aug 2026 10:14:54 -0700 Subject: [PATCH] fix(bin): recover corr-tagged decision verbs without false opens status_line_verb only peeled [key=...], so complete non-key tags such as secondmate [corr=hex] glued onto the verb and the open-decisions fold missed greppable needs-decision/blocked lines - fm-send --resolve-key then refused a plainly open key. Strip a before-colon suffix only when it is a whitespace-separated run of complete [name=value] tags (body rejects space/[, locale-independent ASCII slug names), fall back to the historical [key= peel otherwise, and bump the open-decisions fold version to 4. Regression coverage: corr-tagged open/close, shape I, key-led preserve, malformed-body and punctuated/unicode name refusal (including stock bash 3.2 + en_US.UTF-8), shared classifiers, cursor migration, and end-to-end fm-send false-send guards. --- bin/fm-classify-lib.sh | 73 ++++++++- tests/fm-classify-decision-key.test.sh | 140 ++++++++++++++++++ tests/fm-send-resolve-key.test.sh | 87 +++++++++++ ...m-wake-drain-open-decisions-cursor.test.sh | 41 +++++ 4 files changed, 336 insertions(+), 5 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index c974bc4d20..75d070c847 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -195,11 +195,74 @@ status_is_paused_or_captain_held() { # # the historical one-open-decision-per-task behavior (a bare "resolved:" closes # "default"). A stated key whose slug fails the charset below is rejected (the # folds skip the line), never rewritten to "default". -# The parsers are pure reads of a single line; the verb parser strips any key -# token before the colon so the leading word is recovered cleanly. +# The parsers are pure reads of a single line. Status metadata may carry any +# number of complete whitespace-separated "[name=value]" tags before the colon +# (any name, typically corr= and key=). Verb recovery strips that suffix only +# when every token is strictly well-formed: no whitespace or '[' in a token +# body, and a locale-independent ASCII slug name (explicit A-Za-z0-9._- +# allowlist, not locale-widened ranges). Incomplete or malformed bracket +# material keeps the verb glued so the line stays inert. When the complete-tag +# check fails, the historical peel from the first "[key=" is preserved so +# key-led stray and unclosed-key lines keep folding as they do today. +# head=${v%%\[*} below is only a candidate prefix assigned when the complete-tag +# predicate passes - not an unconditional first-bracket peel. +# 0 when is empty or a whitespace-separated run of complete +# "[name=value]" metadata tags with locale-independent ASCII slug names. +_fm_is_wholly_complete_metadata_tags() { # + local s=$1 token rest name + while [ -n "$s" ]; do + s=${s#"${s%%[![:space:]]*}"} + [ -n "$s" ] || return 0 + case "$s" in + \[*) ;; + *) return 1 ;; + esac + case "$s" in + *\]*) ;; + *) return 1 ;; + esac + rest=${s#\[} + token=${rest%%\]*} + rest=${rest#*\]} + # Fail-closed: token body may not contain whitespace or another '['. + case "$token" in + *[[:space:]]*|*\[*) return 1 ;; + esac + case "$token" in + *=*) + name=${token%%=*} + # Locale-independent ASCII slug name (explicit allowlist; no A-Z ranges). + case "$name" in + ''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) return 1 ;; + esac + ;; + *) return 1 ;; + esac + if [ -n "$rest" ]; then + case "$rest" in + [[:space:]]*) ;; + *) return 1 ;; + esac + fi + s=$rest + done + return 0 +} status_line_verb() { # -> leading verb word - local v=${1%%:*} - v=${v%%\[key=*} + local v=${1%%:*} head tags_region + case "$v" in + *\[*) + head=${v%%\[*} + tags_region=${v#"$head"} + if _fm_is_wholly_complete_metadata_tags "$tags_region"; then + v=$head + else + case "$v" in + *\[key=*) v=${v%%\[key=*} ;; + esac + fi + ;; + esac v=${v#"${v%%[![:space:]]*}"} v=${v%"${v##*[![:space:]]}"} printf '%s' "$v" @@ -454,7 +517,7 @@ _fm_open_decisions_cursor_path() { # printf '%s/.%s.open-decisions-cursor' "$dir" "${base%.status}" } -FM_OPEN_DECISIONS_FOLD_VERSION=3 +FM_OPEN_DECISIONS_FOLD_VERSION=4 # Portable device:inode identity for the rotation/recreation check below. _fm_open_decisions_file_ident() { # -> "dev:inode", empty on I/O failure diff --git a/tests/fm-classify-decision-key.test.sh b/tests/fm-classify-decision-key.test.sh index 62a7a8095f..287dc90014 100755 --- a/tests/fm-classify-decision-key.test.sh +++ b/tests/fm-classify-decision-key.test.sh @@ -171,6 +171,140 @@ test_incremental_agrees_with_full_fold_across_appends() { pass "the incremental fold matches the full fold across appends in both key positions" } +# Metadata-tag verb recovery: complete before-colon [name=value] tags (any +# well-formed name, typically corr=) must not glue onto the verb. Fail-closed +# on malformed bodies/names so phantom keys never open. Key-led stray still +# peels via the historical [key= fallback. +test_corr_then_key_opens_and_closes_under_stated_key() { + local dir expected + dir=$(case_dir corr-then-key) + printf 'needs-decision [corr=d448ea86afa4bf67] [key=project-registration-path]: no proven path\n' \ + > "$dir/t.status" + expected=$(printf 'project-registration-path\tneeds-decision\tno proven path\n') + assert_fold "$dir/t.status" "$expected" "corr-then-key open" + + printf 'resolved [corr=deadbeef] [key=project-registration-path]: answered: courier\n' \ + >> "$dir/t.status" + assert_fold "$dir/t.status" "" "corr-tagged resolved closes stated key" + pass "corr-then-key opens and closes under the stated key" +} + +test_shape_i_corr_before_colon_key_at_note_head() { + local dir expected + dir=$(case_dir shape-i) + printf 'needs-decision [corr=d448ea86afa4bf67]: [key=project-registration-path] pick path\n' \ + > "$dir/t.status" + expected=$(printf 'project-registration-path\tneeds-decision\tpick path\n') + assert_fold "$dir/t.status" "$expected" "shape I open" + printf 'resolved [corr=d448ea86afa4bf67] [key=project-registration-path]: answered: courier\n' \ + >> "$dir/t.status" + assert_fold "$dir/t.status" "" "shape I close" + pass "corr before colon with key at note head opens and closes the stated key" +} + +test_key_led_stray_and_unclosed_still_fold() { + local dir + dir=$(case_dir key-led-preserve) + printf 'needs-decision [key=stray-slug] stray prose: keep key open\n' > "$dir/a.status" + assert_fold "$dir/a.status" \ + "$(printf 'stray-slug\tneeds-decision\tkeep key open\n')" "key-led stray open" + + printf 'needs-decision [key=k1]: open\n' > "$dir/b.status" + printf 'resolved [key=k1] stray prose: answered\n' >> "$dir/b.status" + assert_fold "$dir/b.status" "" "key-led stray close" + + printf 'needs-decision [key=unclosed-slug: missing close\n' > "$dir/c.status" + assert_fold "$dir/c.status" \ + "$(printf 'default\tneeds-decision\tmissing close\n')" "unclosed key peels to default" + pass "key-led stray and unclosed-key lines keep today's fold behavior" +} + +test_malformed_metadata_never_opens_phantom_keys() { + local dir + dir=$(case_dir malformed-meta) + # Cross-token invalid+valid (round-2 codex). + printf 'needs-decision [draft] [corr=x]: must stay inert\n' > "$dir/mixed.status" + assert_fold "$dir/mixed.status" "" "draft then corr" + printf 'needs-decision [draft] [corr=x] [key=phantom]: must stay inert\n' > "$dir/mixedk.status" + assert_fold "$dir/mixedk.status" "" "draft then corr then key" + + # Malformed bodies with embedded [key=...] (round-3). + printf 'needs-decision [corr=abc [key=phantom]: truncated corr\n' > "$dir/trunc.status" + assert_fold "$dir/trunc.status" "" "truncated corr embeds key" + printf 'needs-decision [[key=phantom]: doubled bracket\n' > "$dir/dbl.status" + assert_fold "$dir/dbl.status" "" "doubled open bracket" + printf 'needs-decision [draft[corr=x] [key=phantom]: nested open\n' > "$dir/nest.status" + assert_fold "$dir/nest.status" "" "nested open bracket" + printf 'needs-decision [draft status=review] [key=phantom]: spaced name\n' > "$dir/space.status" + assert_fold "$dir/space.status" "" "spaced name body" + + # Punctuated / locale-widened names (round-4/5). + printf 'needs-decision [draft!status=review] [key=phantom]: punct bang\n' > "$dir/bang.status" + assert_fold "$dir/bang.status" "" "punctuated name bang" + printf 'needs-decision [dräft=review] [key=phantom]: unicode name\n' > "$dir/uni.status" + assert_fold "$dir/uni.status" "" "latin-extended name" + + # Close-side twin: malformed closer must not close a real open key. + printf 'needs-decision [key=realc]: keep open\n' > "$dir/close.status" + printf 'resolved [drop!name=x] [key=realc]: must not close\n' >> "$dir/close.status" + assert_fold "$dir/close.status" \ + "$(printf 'realc\tneeds-decision\tkeep open\n')" "malformed closer leaves realc open" + + # Adjacent tags and empty name stay inert on the complete-tag path. + printf 'needs-decision [corr=x][key=adj]: adjacent\n' > "$dir/adj.status" + assert_fold "$dir/adj.status" "" "adjacent tags" + printf 'needs-decision [[=x]: empty-ish name\n' > "$dir/eq.status" + assert_fold "$dir/eq.status" "" "degenerate body" + + # Shape C grammar control: end-of-note key is prose, opens default only. + printf 'needs-decision: no proven path [key=project-registration-path]\n' > "$dir/end.status" + assert_fold "$dir/end.status" \ + "$(printf 'default\tneeds-decision\tno proven path [key=project-registration-path]\n')" \ + "end-of-note key prose" + + pass "malformed metadata never opens phantom keys; key-end grammar and close twin hold" +} + +test_shared_verb_recovery_for_valid_corr_tags() { + local line + line='needs-decision [corr=d448ea86afa4bf67] [key=project-registration-path]: path' + status_is_captain_relevant "$line" \ + || fail "corr-tagged needs-decision should be captain-relevant" + status_frees_capacity "$line" \ + || fail "corr-tagged needs-decision should free capacity" + + line='resolved [draft]: prose' + status_frees_capacity "$line" \ + && fail "resolved [draft] must not free capacity" + line='resolved [draft] [corr=x]: prose' + status_frees_capacity "$line" \ + && fail "resolved [draft] [corr=x] must not free capacity" + + line='paused [corr=d448ea86afa4bf67]: waiting' + status_is_paused "$line" \ + || fail "corr-tagged paused should match the pause verb" + + local dir + dir=$(case_dir activity) + printf 'working [corr=abc] [key=work-slug]: phase one\n' > "$dir/a.status" + [ "$(status_open_activities "$dir/a.status")" = "$(printf 'work-slug\tworking\tphase one\n')" ] \ + || fail "activity should open under corr-tagged working: $(status_open_activities "$dir/a.status")" + printf 'done [corr=x] [key=work-slug]: finished\n' >> "$dir/a.status" + [ -z "$(status_open_activities "$dir/a.status")" ] \ + || fail "activity should close under corr-tagged done: $(status_open_activities "$dir/a.status")" + + pass "valid corr tags recover shared classifiers; malformed draft tags stay inert" +} + +test_captain_held_with_corr_closes() { + local dir + dir=$(case_dir captain-held-corr) + printf 'needs-decision [key=api-shape]: pick REST or RPC\n' > "$dir/t.status" + printf 'captain-held [corr=aaaa] [key=api-shape]: tracked by hold-1\n' >> "$dir/t.status" + assert_fold "$dir/t.status" "" "captain-held with corr closes" + pass "captain-held with a leading corr tag closes the stated key" +} + test_stated_key_is_honored_in_both_positions test_bare_keyless_line_still_folds_to_default test_resolution_closes_across_positions @@ -179,3 +313,9 @@ test_two_colon_form_decisions_stay_distinct test_mid_note_prose_mention_is_not_a_stated_key test_malformed_stated_key_never_collapses_to_default test_incremental_agrees_with_full_fold_across_appends +test_corr_then_key_opens_and_closes_under_stated_key +test_shape_i_corr_before_colon_key_at_note_head +test_key_led_stray_and_unclosed_still_fold +test_malformed_metadata_never_opens_phantom_keys +test_shared_verb_recovery_for_valid_corr_tags +test_captain_held_with_corr_closes diff --git a/tests/fm-send-resolve-key.test.sh b/tests/fm-send-resolve-key.test.sh index dda4a1626f..e0d2589c5e 100755 --- a/tests/fm-send-resolve-key.test.sh +++ b/tests/fm-send-resolve-key.test.sh @@ -452,6 +452,90 @@ test_flag_misuse_refuses() { pass "fm-send --resolve-key: --key, empty message, explicit targets, and malformed keys refuse loudly" } +# Filed adopted-stack regression: corr-tagged open key must be answerable. +test_corr_tagged_open_key_is_answerable() { + local dir fb log home rc out + dir="$TMP_ROOT/corr-tagged"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); log="$dir/send.log" + home=$(setup_home corr-tagged) + fm_write_meta "$home/state/t-corr.meta" "window=sess:fm-t-corr" "kind=ship" + printf 'needs-decision [corr=d448ea86afa4bf67] [key=project-registration-path]: no proven IPC path\n' \ + > "$home/state/t-corr.status" + + out=$(drain_out "$home") + printf '%s' "$out" | grep -F '[key=project-registration-path]' >/dev/null \ + || fail "precondition: corr-tagged decision should list as open: $out" + + run_send "$fb" "$home" "$log" t-corr --resolve-key project-registration-path "use courier UI path"; rc=$? + expect_code 0 "$rc" "answering a corr-tagged open key should succeed" + assert_contains "$(cat "$log")" "use courier UI path" "the answer text should reach the worker" + grep -F 'resolved [key=project-registration-path]: answered: use courier UI path' \ + "$home/state/t-corr.status" >/dev/null \ + || fail "missing closing resolved line:"$'\n'"$(cat "$home/state/t-corr.status")" + + out=$(drain_out "$home") + if printf '%s' "$out" | grep -F 'OPEN DECISIONS' >/dev/null; then + fail "answered corr-tagged decision still lists as open: $out" + fi + pass "fm-send --resolve-key: a corr-tagged open key is answerable end to end" +} + +# Fail-closed: malformed metadata must not invent a phantom open key that sends. +test_malformed_metadata_refuses_phantom_resolve_key() { + local dir fb log home err rc + dir="$TMP_ROOT/phantom-refuse"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); log="$dir/send.log"; err="$dir/send.err" + home=$(setup_home phantom-refuse) + fm_write_meta "$home/state/t-ph.meta" "window=sess:fm-t-ph" "kind=ship" + + refuse_case() { #