1111# library eliminates drift and reduces maintenance surface. Functions are
1212# exported for subshell access. Cache keyed on HEAD+mod_time for performance.
1313#
14+ # @decision DEC-SIGPIPE-001
15+ # @title Replace echo|grep and awk|head pipe patterns with SIGPIPE-safe equivalents
16+ # @status accepted
17+ # @rationale Under set -euo pipefail, any pipe where the reader closes before the
18+ # writer finishes (SIGPIPE) propagates exit 141 and kills the hook. Two patterns
19+ # were dangerous: (1) `echo "$var" | grep -qE` in tight while-read loops over
20+ # large plan sections — each spawns a subshell+pipe, and on macOS the shell
21+ # delivers SIGPIPE to the writer when grep exits early; (2) multi-stage pipes
22+ # like `grep | tail | sed | paste` in get_research_status(). Fixes applied:
23+ # Pattern B — replace `echo "$_line" | grep -qE 'pat'` with `[[ "$_line" =~ pat ]]`
24+ # (no subshell, no pipe). Pattern E — replace multi-stage pipe with a single awk
25+ # program that collects, filters, and formats in one process. See DEC-SIGPIPE-001
26+ # in session-init.sh for Pattern A (awk|head → inline awk limit) and Pattern C
27+ # (echo|sed → bash parameter expansion).
28+ #
1429# Provides:
1530# get_git_state <project_root> - Populates GIT_BRANCH, GIT_DIRTY_COUNT,
1631# GIT_WORKTREES, GIT_WT_COUNT
@@ -127,7 +142,10 @@ get_plan_status() {
127142 if [[ -n " $_active_section " ]]; then
128143 local _in_init=false _init_status=" "
129144 while IFS= read -r _line; do
130- if echo " $_line " | grep -qE ' ^\#\#\#\s+Initiative:' ; then
145+ # Pattern B: [[ =~ ]] replaces echo "$_line" | grep -qE (DEC-SIGPIPE-001).
146+ # Each grep in a tight read loop spawns a subshell+pipe; when the section
147+ # is large (1000+ lines), any broken pipe propagates exit 141 under pipefail.
148+ if [[ " $_line " =~ ^' ###' [[:space:]]+' Initiative:' ]]; then
131149 # Finalize previous initiative
132150 if [[ " $_in_init " == " true" ]]; then
133151 if [[ " $_init_status " == " active" ]]; then
@@ -138,12 +156,13 @@ get_plan_status() {
138156 fi
139157 _in_init=true
140158 _init_status=" "
141- elif [[ " $_in_init " == " true" && -z " $_init_status " ]] && \
142- echo " $_line " | grep -qE ' ^\*\*Status:\*\*' ; then
159+ elif [[ " $_in_init " == " true" && -z " $_init_status " && " $_line " =~ ^\*\* Status:\*\* ]]; then
143160 # First Status line after the Initiative header is the initiative status
144- if echo " $_line " | grep -qiE ' active' ; then
161+ # Case-insensitive match via [[ =~ ]] — bash 3.2 compatible (no ${var,,}).
162+ # macOS ships bash 3.2 which lacks ,, (lowercase) operator.
163+ if [[ " $_line " =~ [Aa]ctive ]]; then
145164 _init_status=" active"
146- elif echo " $_line " | grep -qiE ' completed ' ; then
165+ elif [[ " $_line " =~ [Cc]ompleted ]] ; then
147166 _init_status=" completed"
148167 fi
149168 fi
@@ -635,7 +654,21 @@ get_research_status() {
635654 RESEARCH_EXISTS=true
636655 RESEARCH_ENTRY_COUNT=$( grep -c ' ^### \[' " $log " 2> /dev/null || true)
637656 RESEARCH_ENTRY_COUNT=${RESEARCH_ENTRY_COUNT:- 0}
638- RESEARCH_RECENT_TOPICS=$( grep ' ^### \[' " $log " | tail -3 | sed ' s/^### \[[^]]*\] //' | paste -sd ' , ' - 2> /dev/null || echo " " )
657+ # Pattern E: replace grep|tail|sed|paste multi-stage pipe with awk (DEC-SIGPIPE-001).
658+ # Multi-stage pipes under set -euo pipefail can SIGPIPE when upstream produces more
659+ # output than downstream reads. awk handles the full pipeline in one process: collect
660+ # matching lines into an array, print the last 3 joined by ', '.
661+ RESEARCH_RECENT_TOPICS=$( awk ' /^\#\#\# \[/{
662+ # Strip the "### [date] " prefix: remove up to and including first "] "
663+ sub(/^\#\#\# \[[^]]*\] /, "")
664+ topics[++n] = $0
665+ }
666+ END {
667+ start = (n > 3) ? n - 2 : 1
668+ sep = ""
669+ for (i = start; i <= n; i++) { printf "%s%s", sep, topics[i]; sep = ", " }
670+ print ""
671+ }' " $log " 2> /dev/null || echo " " )
639672}
640673
641674# --- Constants ---
@@ -2487,18 +2520,19 @@ compress_initiative() {
24872520
24882521 # Extract the initiative block from Active Initiatives section
24892522 # Block starts at "### Initiative: <name>" and ends before the next "### Initiative:" or "## "
2523+ # Pattern B: [[ =~ ]] replaces echo "$_line" | grep -qE throughout this function (DEC-SIGPIPE-001).
24902524 local _init_block=" "
24912525 local _in_block=false
24922526 local _started_line
24932527 while IFS= read -r _line; do
2494- if echo " $_line " | grep -qE " ^ ### Initiative: ${init_name} $ " ; then
2528+ if [[ " $_line " == " ### Initiative: ${init_name} " ]] ; then
24952529 _in_block=true
24962530 _init_block=" ${_line} " $' \n '
24972531 continue
24982532 fi
24992533 if [[ " $_in_block " == " true" ]]; then
25002534 # Stop at next ### Initiative: or ## section header
2501- if echo " $_line " | grep -qE ' ^ ### Initiative:|^ ## ' ; then
2535+ if [[ " $_line " =~ ^ ' ### Initiative:' | ^ ' ## ' ]] ; then
25022536 break
25032537 fi
25042538 _init_block+=" ${_line} " $' \n '
@@ -2533,33 +2567,33 @@ compress_initiative() {
25332567 local _appended=false
25342568
25352569 while IFS= read -r _line; do
2536- # Track section boundaries
2537- if echo " $_line " | grep -qE ' ^ ## Active Initiatives' ; then
2570+ # Track section boundaries — Pattern B: [[ =~ ]] replaces echo|grep-qE (DEC-SIGPIPE-001)
2571+ if [[ " $_line " == " ## Active Initiatives" ]] ; then
25382572 _in_active=true
25392573 _in_completed=false
25402574 printf ' %s\n' " $_line " >> " $_tmp_file "
25412575 continue
25422576 fi
2543- if echo " $_line " | grep -qE ' ^ ## Completed Initiatives' ; then
2577+ if [[ " $_line " == " ## Completed Initiatives" ]] ; then
25442578 _in_active=false
25452579 _in_completed=true
25462580 printf ' %s\n' " $_line " >> " $_tmp_file "
25472581 continue
25482582 fi
2549- if echo " $_line " | grep -qE ' ^ ## ' && ! echo " $_line " | grep -qE ' ^ ## Active|^ ## Completed' ; then
2583+ if [[ " $_line " =~ ^ ' ## ' && " $_line " != " ## Active Initiatives " && " $_line " != " ## Completed Initiatives " ]] ; then
25502584 _in_active=false
25512585 _in_completed=false
25522586 fi
25532587
25542588 # In Active section: skip the target initiative block
25552589 if [[ " $_in_active " == " true" ]]; then
2556- if echo " $_line " | grep -qE " ^ ### Initiative: ${init_name} $ " ; then
2590+ if [[ " $_line " == " ### Initiative: ${init_name} " ]] ; then
25572591 _skip_block=true
25582592 continue
25592593 fi
25602594 if [[ " $_skip_block " == " true" ]]; then
2561- # End of block: next ### Initiative: or ## section
2562- if echo " $_line " | grep -qE ' ^ ### Initiative:|^ ## ' ; then
2595+ # End of block: next ### Initiative: or ## section header
2596+ if [[ " $_line " =~ ^ ' ### Initiative:' | ^ ' ## ' ]] ; then
25632597 _skip_block=false
25642598 # Don't skip this line — it starts the next block
25652599 printf ' %s\n' " $_line " >> " $_tmp_file "
@@ -2569,11 +2603,11 @@ compress_initiative() {
25692603 fi
25702604 fi
25712605
2572- # In Completed section: append compressed row after the table header if not yet done
2606+ # In Completed section: append compressed row after the table separator if not yet done
25732607 if [[ " $_in_completed " == " true" && " $_appended " == " false" ]]; then
25742608 printf ' %s\n' " $_line " >> " $_tmp_file "
2575- # After the header row (| --- | line), append the compressed row
2576- if echo " $_line " | grep -qE ' ^\|[-| ]+\|' ; then
2609+ # After the separator row (| --- | line), append the compressed row
2610+ if [[ " $_line " =~ ^\| [-\ | ]+\| ]] ; then
25772611 printf ' %s\n' " $_compressed_row " >> " $_tmp_file "
25782612 _appended=true
25792613 fi
0 commit comments