devforgeai 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +120 -0
- package/bin/devforgeai.js +0 -0
- package/package.json +9 -1
- package/src/CLAUDE.md +699 -0
- package/src/claude/hooks/phase-completion-gate.sh +0 -0
- package/src/claude/scripts/README.md +396 -0
- package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
- package/src/claude/scripts/check-hooks-fast.sh +70 -0
- package/src/claude/scripts/devforgeai-validate +6 -0
- package/src/claude/scripts/devforgeai_cli/README.md +531 -0
- package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
- package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
- package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
- package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
- package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
- package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
- package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
- package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
- package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
- package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
- package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
- package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
- package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
- package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
- package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
- package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
- package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
- package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
- package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
- package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
- package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
- package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
- package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
- package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
- package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
- package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
- package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
- package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
- package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
- package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
- package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
- package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
- package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
- package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
- package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
- package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
- package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
- package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
- package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
- package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
- package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
- package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
- package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
- package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
- package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
- package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
- package/src/claude/scripts/install_hooks.sh +186 -0
- package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
- package/src/claude/scripts/migrate-ac-headers.sh +122 -0
- package/src/claude/scripts/plan_file_kb.sh +704 -0
- package/src/claude/scripts/requirements.txt +8 -0
- package/src/claude/scripts/session_catalog.sh +543 -0
- package/src/claude/scripts/setup.py +55 -0
- package/src/claude/scripts/start-devforgeai.sh +16 -0
- package/src/claude/scripts/statusline.sh +27 -0
- package/src/claude/scripts/validate_deferrals.py +344 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/detect_anti_patterns.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_all_context.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_architecture.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_dependencies.cpython-312.pyc +0 -0
- package/src/claude/skills/devforgeai-story-creation/scripts/__pycache__/migrate_story_v1_to_v2.cpython-312.pyc +0 -0
- package/src/claude/skills/devforgeai-story-creation/scripts/tests/__pycache__/measure_accuracy.cpython-312.pyc +0 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Plan File Knowledge Base Functions
|
|
3
|
+
# STORY-222: Extract Plan File Knowledge Base for Decision Archive
|
|
4
|
+
#
|
|
5
|
+
# Provides 4 functions for plan file parsing and decision archive building:
|
|
6
|
+
# - extract_yaml_frontmatter: Parse YAML frontmatter from plan files
|
|
7
|
+
# - extract_story_ids: Extract STORY-NNN patterns with context
|
|
8
|
+
# - build_decision_archive: Build bidirectional story<->plan mapping
|
|
9
|
+
# - query_archive: Query archive for related plan files
|
|
10
|
+
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
# =============================================================================
|
|
14
|
+
# Helper Functions
|
|
15
|
+
# =============================================================================
|
|
16
|
+
|
|
17
|
+
# Validate that a file exists before processing
|
|
18
|
+
# Usage: validate_file_exists "$file_path"
|
|
19
|
+
# Returns: 0 if exists, 1 if not (also outputs JSON error)
|
|
20
|
+
validate_file_exists() {
|
|
21
|
+
local file="$1"
|
|
22
|
+
if [[ ! -f "$file" ]]; then
|
|
23
|
+
echo '{"error": "File not found"}'
|
|
24
|
+
return 1
|
|
25
|
+
fi
|
|
26
|
+
return 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Escape special characters for JSON output
|
|
30
|
+
# Usage: json_escape "$string"
|
|
31
|
+
# Handles: backslash, quotes, newlines, carriage returns, tabs
|
|
32
|
+
json_escape() {
|
|
33
|
+
local str="$1"
|
|
34
|
+
str="${str//\\/\\\\}"
|
|
35
|
+
str="${str//\"/\\\"}"
|
|
36
|
+
str="${str//$'\n'/\\n}"
|
|
37
|
+
str="${str//$'\r'/\\r}"
|
|
38
|
+
str="${str//$'\t'/\\t}"
|
|
39
|
+
echo "$str"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Build a JSON array from newline-separated lines
|
|
43
|
+
# Usage: json_array_from_lines < input_lines
|
|
44
|
+
# Example: echo -e "STORY-001\nSTORY-002" | json_array_from_lines
|
|
45
|
+
json_array_from_lines() {
|
|
46
|
+
awk 'BEGIN{printf "["} NR>1{printf ", "} {printf "\"%s\"", $0} END{printf "]"}'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Parse a specific YAML field from content
|
|
50
|
+
# Usage: parse_yaml_field "$yaml_content" "status"
|
|
51
|
+
# Returns: field value without quotes
|
|
52
|
+
parse_yaml_field() {
|
|
53
|
+
local content="$1"
|
|
54
|
+
local field_name="$2"
|
|
55
|
+
echo "$content" | grep -E "^${field_name}:" | sed "s/^${field_name}:[[:space:]]*//" | tr -d '"' | tr -d "'" || echo ""
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# =============================================================================
|
|
59
|
+
# Function 1: extract_yaml_frontmatter
|
|
60
|
+
# AC#1: Parse YAML frontmatter from plan files
|
|
61
|
+
# SM-010: Parse YAML frontmatter from plan files (Critical)
|
|
62
|
+
# =============================================================================
|
|
63
|
+
extract_yaml_frontmatter() {
|
|
64
|
+
local plan_file="$1"
|
|
65
|
+
|
|
66
|
+
validate_file_exists "$plan_file" || return 1
|
|
67
|
+
|
|
68
|
+
# Check if file starts with ---
|
|
69
|
+
local first_line
|
|
70
|
+
first_line=$(head -n1 "$plan_file" 2>/dev/null || echo "")
|
|
71
|
+
|
|
72
|
+
if [[ "$first_line" != "---" ]]; then
|
|
73
|
+
# No YAML frontmatter - return empty/default
|
|
74
|
+
echo '{"status": "", "created": "", "author": "", "related_stories": []}'
|
|
75
|
+
return 0
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
# Extract content between first and second ---
|
|
79
|
+
local yaml_content
|
|
80
|
+
yaml_content=$(awk 'BEGIN{found=0} /^---$/{found++; if(found==2) exit; next} found==1{print}' "$plan_file" 2>/dev/null || echo "")
|
|
81
|
+
|
|
82
|
+
if [[ -z "$yaml_content" ]]; then
|
|
83
|
+
echo '{"status": "", "created": "", "author": "", "related_stories": []}'
|
|
84
|
+
return 0
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# Parse individual fields from YAML using helper
|
|
88
|
+
local status
|
|
89
|
+
status=$(parse_yaml_field "$yaml_content" "status")
|
|
90
|
+
|
|
91
|
+
local created
|
|
92
|
+
created=$(parse_yaml_field "$yaml_content" "created")
|
|
93
|
+
|
|
94
|
+
local author
|
|
95
|
+
author=$(parse_yaml_field "$yaml_content" "author")
|
|
96
|
+
|
|
97
|
+
# Extract related_stories array
|
|
98
|
+
local related_stories
|
|
99
|
+
related_stories=$(parse_related_stories "$yaml_content")
|
|
100
|
+
|
|
101
|
+
# Output JSON
|
|
102
|
+
printf '{"status": "%s", "created": "%s", "author": "%s", "related_stories": %s}\n' \
|
|
103
|
+
"$status" "$created" "$author" "$related_stories"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Helper to parse related_stories field (handles inline and multiline formats)
|
|
107
|
+
parse_related_stories() {
|
|
108
|
+
local yaml_content="$1"
|
|
109
|
+
local related_stories_field
|
|
110
|
+
related_stories_field=$(echo "$yaml_content" | grep -E "^related_stories:" || echo "")
|
|
111
|
+
|
|
112
|
+
if [[ -z "$related_stories_field" ]]; then
|
|
113
|
+
echo "[]"
|
|
114
|
+
return 0
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
# Try to extract STORY-XXX patterns from the field
|
|
118
|
+
local stories_array
|
|
119
|
+
stories_array=$(echo "$yaml_content" | grep -E "^related_stories:" | \
|
|
120
|
+
sed 's/^related_stories:[[:space:]]*//' | \
|
|
121
|
+
grep -oE 'STORY-[0-9]+' | \
|
|
122
|
+
json_array_from_lines)
|
|
123
|
+
|
|
124
|
+
# If no patterns found, return empty array
|
|
125
|
+
if [[ -z "$stories_array" || "$stories_array" == "[]" ]]; then
|
|
126
|
+
echo "[]"
|
|
127
|
+
else
|
|
128
|
+
echo "$stories_array"
|
|
129
|
+
fi
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# =============================================================================
|
|
133
|
+
# Function 2: extract_story_ids
|
|
134
|
+
# AC#2: Extract STORY-NNN patterns with surrounding context
|
|
135
|
+
# SM-011: Extract STORY-NNN patterns with regex (High)
|
|
136
|
+
# =============================================================================
|
|
137
|
+
extract_story_ids() {
|
|
138
|
+
local plan_file="$1"
|
|
139
|
+
|
|
140
|
+
validate_file_exists "$plan_file" || return 1
|
|
141
|
+
|
|
142
|
+
# Extract all STORY-NNN patterns (3+ digits)
|
|
143
|
+
local story_ids
|
|
144
|
+
story_ids=$(grep -oE 'STORY-[0-9]{3,}' "$plan_file" 2>/dev/null | sort -u || echo "")
|
|
145
|
+
|
|
146
|
+
if [[ -z "$story_ids" ]]; then
|
|
147
|
+
echo '{"story_ids": [], "contexts": {}}'
|
|
148
|
+
return 0
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
# Build JSON array of story IDs using helper
|
|
152
|
+
local ids_json
|
|
153
|
+
ids_json=$(echo "$story_ids" | json_array_from_lines)
|
|
154
|
+
|
|
155
|
+
# Build contexts object with surrounding text for each story ID
|
|
156
|
+
local contexts_json="{"
|
|
157
|
+
local is_first_id_entry=true
|
|
158
|
+
|
|
159
|
+
while IFS= read -r story_id; do
|
|
160
|
+
if [[ -z "$story_id" ]]; then continue; fi
|
|
161
|
+
|
|
162
|
+
# Get line with context (extract the line containing the story ID)
|
|
163
|
+
local context
|
|
164
|
+
context=$(grep -m1 "$story_id" "$plan_file" 2>/dev/null | head -c 200 | tr '\n' ' ' | tr '"' "'" || echo "")
|
|
165
|
+
|
|
166
|
+
if [[ "$is_first_id_entry" == "true" ]]; then
|
|
167
|
+
is_first_id_entry=false
|
|
168
|
+
else
|
|
169
|
+
contexts_json+=", "
|
|
170
|
+
fi
|
|
171
|
+
contexts_json+="\"$story_id\": \"$context\""
|
|
172
|
+
done <<< "$story_ids"
|
|
173
|
+
|
|
174
|
+
contexts_json+="}"
|
|
175
|
+
|
|
176
|
+
printf '{"story_ids": %s, "contexts": %s}\n' "$ids_json" "$contexts_json"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# =============================================================================
|
|
180
|
+
# Function 3: build_decision_archive
|
|
181
|
+
# AC#3: Build bidirectional story<->decision mapping
|
|
182
|
+
# SM-012: Build story→decision bidirectional mapping (High)
|
|
183
|
+
# NFR-010: Index 350+ plan files within 10 seconds
|
|
184
|
+
# =============================================================================
|
|
185
|
+
# Process a single plan file and update mappings
|
|
186
|
+
process_plan_file() {
|
|
187
|
+
local plan_file="$1"
|
|
188
|
+
local -n story_to_plans_ref=$2
|
|
189
|
+
local -n plan_to_stories_ref=$3
|
|
190
|
+
local -n plan_metadata_ref=$4
|
|
191
|
+
|
|
192
|
+
local plan_name
|
|
193
|
+
plan_name=$(basename "$plan_file")
|
|
194
|
+
|
|
195
|
+
# Extract frontmatter
|
|
196
|
+
local frontmatter
|
|
197
|
+
frontmatter=$(extract_yaml_frontmatter "$plan_file")
|
|
198
|
+
plan_metadata_ref["$plan_name"]="$frontmatter"
|
|
199
|
+
|
|
200
|
+
# Extract story IDs
|
|
201
|
+
local story_ids
|
|
202
|
+
story_ids=$(grep -oE 'STORY-[0-9]+' "$plan_file" 2>/dev/null | sort -u || echo "")
|
|
203
|
+
|
|
204
|
+
# Build plan_to_stories mapping using helper
|
|
205
|
+
if [[ -n "$story_ids" ]]; then
|
|
206
|
+
local stories_array
|
|
207
|
+
stories_array=$(echo "$story_ids" | json_array_from_lines)
|
|
208
|
+
plan_to_stories_ref["$plan_name"]="$stories_array"
|
|
209
|
+
|
|
210
|
+
# Build story_to_plans mapping (bidirectional)
|
|
211
|
+
while IFS= read -r story_id; do
|
|
212
|
+
if [[ -n "$story_id" ]]; then
|
|
213
|
+
if [[ -n "${story_to_plans_ref[$story_id]:-}" ]]; then
|
|
214
|
+
story_to_plans_ref["$story_id"]+=", \"$plan_name\""
|
|
215
|
+
else
|
|
216
|
+
story_to_plans_ref["$story_id"]="\"$plan_name\""
|
|
217
|
+
fi
|
|
218
|
+
fi
|
|
219
|
+
done <<< "$story_ids"
|
|
220
|
+
else
|
|
221
|
+
plan_to_stories_ref["$plan_name"]="[]"
|
|
222
|
+
fi
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# Build JSON object from associative array
|
|
226
|
+
build_json_object_from_array() {
|
|
227
|
+
local -n array_ref=$1
|
|
228
|
+
local json_str="{"
|
|
229
|
+
local is_first=true
|
|
230
|
+
|
|
231
|
+
for key in "${!array_ref[@]}"; do
|
|
232
|
+
if [[ "$is_first" == "true" ]]; then
|
|
233
|
+
is_first=false
|
|
234
|
+
else
|
|
235
|
+
json_str+=", "
|
|
236
|
+
fi
|
|
237
|
+
json_str+="\"$key\": ${array_ref[$key]}"
|
|
238
|
+
done
|
|
239
|
+
json_str+="}"
|
|
240
|
+
echo "$json_str"
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Build JSON array of plan names from story_to_plans mapping
|
|
244
|
+
build_story_to_plans_json() {
|
|
245
|
+
local -n array_ref=$1
|
|
246
|
+
local json_str="{"
|
|
247
|
+
local is_first=true
|
|
248
|
+
|
|
249
|
+
for story_id in "${!array_ref[@]}"; do
|
|
250
|
+
if [[ "$is_first" == "true" ]]; then
|
|
251
|
+
is_first=false
|
|
252
|
+
else
|
|
253
|
+
json_str+=", "
|
|
254
|
+
fi
|
|
255
|
+
json_str+="\"$story_id\": [${array_ref[$story_id]}]"
|
|
256
|
+
done
|
|
257
|
+
json_str+="}"
|
|
258
|
+
echo "$json_str"
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
build_decision_archive() {
|
|
262
|
+
local plans_dir="$1"
|
|
263
|
+
local archive_dir="$2"
|
|
264
|
+
|
|
265
|
+
if [[ ! -d "$plans_dir" ]]; then
|
|
266
|
+
echo '{"error": "Plans directory not found"}'
|
|
267
|
+
return 1
|
|
268
|
+
fi
|
|
269
|
+
|
|
270
|
+
# Create archive directory if it doesn't exist
|
|
271
|
+
mkdir -p "$archive_dir"
|
|
272
|
+
|
|
273
|
+
# Initialize data structures
|
|
274
|
+
declare -A story_to_plans
|
|
275
|
+
declare -A plan_to_stories
|
|
276
|
+
declare -A plan_metadata
|
|
277
|
+
|
|
278
|
+
# Process all plan files
|
|
279
|
+
local plan_count=0
|
|
280
|
+
while IFS= read -r -d '' plan_file; do
|
|
281
|
+
process_plan_file "$plan_file" story_to_plans plan_to_stories plan_metadata
|
|
282
|
+
((plan_count++))
|
|
283
|
+
done < <(find "$plans_dir" -name "*.md" -type f -print0 2>/dev/null)
|
|
284
|
+
|
|
285
|
+
# Build final JSON using helpers
|
|
286
|
+
local story_to_plans_json
|
|
287
|
+
story_to_plans_json=$(build_story_to_plans_json story_to_plans)
|
|
288
|
+
|
|
289
|
+
local plan_to_stories_json
|
|
290
|
+
plan_to_stories_json=$(build_json_object_from_array plan_to_stories)
|
|
291
|
+
|
|
292
|
+
local plan_metadata_json
|
|
293
|
+
plan_metadata_json=$(build_json_object_from_array plan_metadata)
|
|
294
|
+
|
|
295
|
+
local archive_json
|
|
296
|
+
archive_json="{\"story_to_plans\": $story_to_plans_json, \"plan_to_stories\": $plan_to_stories_json, \"metadata\": $plan_metadata_json}"
|
|
297
|
+
|
|
298
|
+
# Write archive to file
|
|
299
|
+
echo "$archive_json" > "$archive_dir/decision_archive.json"
|
|
300
|
+
|
|
301
|
+
# Return summary
|
|
302
|
+
printf '{"status": "success", "plan_count": %d, "archive_path": "%s/decision_archive.json"}\n' \
|
|
303
|
+
"$plan_count" "$archive_dir"
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# =============================================================================
|
|
307
|
+
# Function 4: query_archive
|
|
308
|
+
# AC#4: Query archive for related plan files
|
|
309
|
+
# =============================================================================
|
|
310
|
+
# Extract plans for a story from archive content
|
|
311
|
+
extract_plans_from_archive() {
|
|
312
|
+
local archive_content="$1"
|
|
313
|
+
local story_id="$2"
|
|
314
|
+
|
|
315
|
+
# Try to extract story_to_plans entry
|
|
316
|
+
local plans_array
|
|
317
|
+
plans_array=$(echo "$archive_content" | grep -oE "\"$story_id\": *\[[^]]*\]" | \
|
|
318
|
+
sed 's/.*\[\([^]]*\)\].*/[\1]/' | head -1 || echo "[]")
|
|
319
|
+
|
|
320
|
+
# If not found, try alternate pattern
|
|
321
|
+
if [[ -z "$plans_array" || "$plans_array" == "[]" ]]; then
|
|
322
|
+
local story_entry
|
|
323
|
+
story_entry=$(echo "$archive_content" | grep -o "\"$story_id\": \[[^]]*\]" || echo "")
|
|
324
|
+
if [[ -n "$story_entry" ]]; then
|
|
325
|
+
plans_array=$(echo "$story_entry" | sed 's/.*\[\(.*\)\]/[\1]/')
|
|
326
|
+
fi
|
|
327
|
+
fi
|
|
328
|
+
|
|
329
|
+
echo "$plans_array"
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
query_archive() {
|
|
333
|
+
local archive_dir="$1"
|
|
334
|
+
local story_id="$2"
|
|
335
|
+
|
|
336
|
+
local archive_file="$archive_dir/decision_archive.json"
|
|
337
|
+
|
|
338
|
+
if [[ ! -f "$archive_file" ]]; then
|
|
339
|
+
echo '{"error": "Archive not found", "story_id": "'"$story_id"'", "plans": []}'
|
|
340
|
+
return 1
|
|
341
|
+
fi
|
|
342
|
+
|
|
343
|
+
# Read archive
|
|
344
|
+
local archive_content
|
|
345
|
+
archive_content=$(cat "$archive_file")
|
|
346
|
+
|
|
347
|
+
# Extract plans using helper
|
|
348
|
+
local plans_array
|
|
349
|
+
plans_array=$(extract_plans_from_archive "$archive_content" "$story_id")
|
|
350
|
+
|
|
351
|
+
if [[ -z "$plans_array" || "$plans_array" == "[]" ]]; then
|
|
352
|
+
echo '{"story_id": "'"$story_id"'", "plans": [], "count": 0}'
|
|
353
|
+
return 0
|
|
354
|
+
fi
|
|
355
|
+
|
|
356
|
+
# Count plans
|
|
357
|
+
local count
|
|
358
|
+
count=$(echo "$plans_array" | grep -oE '"[^"]*\.md"' | wc -l || echo "0")
|
|
359
|
+
|
|
360
|
+
# Build result with plan details
|
|
361
|
+
local result='{"story_id": "'"$story_id"'", "plans": '"$plans_array"', "count": '"$count"'}'
|
|
362
|
+
|
|
363
|
+
echo "$result"
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
# =============================================================================
|
|
367
|
+
# Helper: Get archive statistics
|
|
368
|
+
# =============================================================================
|
|
369
|
+
get_archive_stats() {
|
|
370
|
+
local archive_dir="$1"
|
|
371
|
+
local archive_file="$archive_dir/decision_archive.json"
|
|
372
|
+
|
|
373
|
+
if [[ ! -f "$archive_file" ]]; then
|
|
374
|
+
echo '{"error": "Archive not found"}'
|
|
375
|
+
return 1
|
|
376
|
+
fi
|
|
377
|
+
|
|
378
|
+
local story_count
|
|
379
|
+
story_count=$(grep -oE '"STORY-[0-9]+":' "$archive_file" | sort -u | wc -l || echo "0")
|
|
380
|
+
|
|
381
|
+
local plan_count
|
|
382
|
+
plan_count=$(grep -oE '"[^"]+\.md":' "$archive_file" | sort -u | wc -l || echo "0")
|
|
383
|
+
|
|
384
|
+
printf '{"story_count": %d, "plan_count": %d}\n' "$story_count" "$plan_count"
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
# =============================================================================
|
|
388
|
+
# STORY-232 Helper: Extract markdown section by header name
|
|
389
|
+
# Usage: extract_markdown_section "$file" "Section Name"
|
|
390
|
+
# Extracts content from "## Section Name" to next "##" or EOF
|
|
391
|
+
# =============================================================================
|
|
392
|
+
extract_markdown_section() {
|
|
393
|
+
local file="$1"
|
|
394
|
+
local section_name="$2"
|
|
395
|
+
|
|
396
|
+
awk -v section="$section_name" '
|
|
397
|
+
$0 ~ ("^## " section "$") { capture=1; next }
|
|
398
|
+
/^## / && capture { capture=0 }
|
|
399
|
+
capture { print }
|
|
400
|
+
' "$file" 2>/dev/null | sed '/^$/d' | tr '\n' ' ' | sed 's/ */ /g' | sed 's/^ //;s/ $//' || echo ""
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
# =============================================================================
|
|
404
|
+
# STORY-232 Helper: Extract JSON field value from JSON string
|
|
405
|
+
# Usage: extract_json_field "$json_string" "field_name"
|
|
406
|
+
# =============================================================================
|
|
407
|
+
extract_json_field() {
|
|
408
|
+
local json_string="$1"
|
|
409
|
+
local field_name="$2"
|
|
410
|
+
|
|
411
|
+
echo "$json_string" | grep -oE "\"$field_name\": *\"[^\"]*\"" | \
|
|
412
|
+
sed "s/\"$field_name\": *\"//" | sed 's/"$//' || echo ""
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
# =============================================================================
|
|
416
|
+
# STORY-232 Helper: Add keyword-to-plan mapping
|
|
417
|
+
# Usage: add_keyword_mapping keyword_array_name "$keyword" "$plan_name"
|
|
418
|
+
# =============================================================================
|
|
419
|
+
add_keyword_mapping() {
|
|
420
|
+
local -n keyword_map_ref=$1
|
|
421
|
+
local keyword="$2"
|
|
422
|
+
local plan_name="$3"
|
|
423
|
+
|
|
424
|
+
if [[ -z "$keyword" ]]; then return; fi
|
|
425
|
+
|
|
426
|
+
if [[ -n "${keyword_map_ref[$keyword]:-}" ]]; then
|
|
427
|
+
# Check if plan already in list
|
|
428
|
+
if ! echo "${keyword_map_ref[$keyword]}" | grep -q "\"$plan_name\""; then
|
|
429
|
+
keyword_map_ref["$keyword"]="${keyword_map_ref[$keyword]}, \"$plan_name\""
|
|
430
|
+
fi
|
|
431
|
+
else
|
|
432
|
+
keyword_map_ref["$keyword"]="\"$plan_name\""
|
|
433
|
+
fi
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
# =============================================================================
|
|
437
|
+
# STORY-232: Function 5: extract_decision_sections
|
|
438
|
+
# AC#2: Extract ## Decision and ## Technical Approach sections from plan file
|
|
439
|
+
# Usage: extract_decision_sections "$plan_file"
|
|
440
|
+
# Returns: JSON with "decision" and "technical_approach" keys
|
|
441
|
+
# =============================================================================
|
|
442
|
+
extract_decision_sections() {
|
|
443
|
+
local plan_file="${1:-}"
|
|
444
|
+
|
|
445
|
+
# Validate argument provided
|
|
446
|
+
if [[ -z "$plan_file" ]]; then
|
|
447
|
+
echo '{"error": "plan_file argument required"}'
|
|
448
|
+
return 1
|
|
449
|
+
fi
|
|
450
|
+
|
|
451
|
+
# Validate path doesn't contain traversal
|
|
452
|
+
if [[ "$plan_file" == *".."* ]]; then
|
|
453
|
+
echo '{"error": "Path traversal not allowed"}'
|
|
454
|
+
return 1
|
|
455
|
+
fi
|
|
456
|
+
|
|
457
|
+
validate_file_exists "$plan_file" || return 1
|
|
458
|
+
|
|
459
|
+
# Extract sections using helper
|
|
460
|
+
local decision_content
|
|
461
|
+
decision_content=$(extract_markdown_section "$plan_file" "Decision")
|
|
462
|
+
|
|
463
|
+
local technical_approach_content
|
|
464
|
+
technical_approach_content=$(extract_markdown_section "$plan_file" "Technical Approach")
|
|
465
|
+
|
|
466
|
+
# Escape content for JSON
|
|
467
|
+
local escaped_decision
|
|
468
|
+
escaped_decision=$(json_escape "$decision_content")
|
|
469
|
+
|
|
470
|
+
local escaped_technical
|
|
471
|
+
escaped_technical=$(json_escape "$technical_approach_content")
|
|
472
|
+
|
|
473
|
+
# Return JSON
|
|
474
|
+
printf '{"decision": "%s", "technical_approach": "%s"}\n' "$escaped_decision" "$escaped_technical"
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
# =============================================================================
|
|
478
|
+
# STORY-232: Function 6: build_searchable_index
|
|
479
|
+
# AC#1: Build searchable index with frontmatter (story ID, status, created date)
|
|
480
|
+
# AC#3: Create searchable_index.json with full-text content
|
|
481
|
+
# Usage: build_searchable_index "$plans_dir" "$index_dir"
|
|
482
|
+
# Returns: JSON with "status", "plan_count", "index_path" keys
|
|
483
|
+
# =============================================================================
|
|
484
|
+
build_searchable_index() {
|
|
485
|
+
local plans_dir="${1:-}"
|
|
486
|
+
local index_dir="${2:-}"
|
|
487
|
+
|
|
488
|
+
# Validate arguments provided
|
|
489
|
+
if [[ -z "$plans_dir" ]] || [[ -z "$index_dir" ]]; then
|
|
490
|
+
echo '{"error": "plans_dir and index_dir arguments required"}'
|
|
491
|
+
return 1
|
|
492
|
+
fi
|
|
493
|
+
|
|
494
|
+
# Validate paths don't contain traversal
|
|
495
|
+
if [[ "$plans_dir" == *".."* ]] || [[ "$index_dir" == *".."* ]]; then
|
|
496
|
+
echo '{"error": "Path traversal not allowed"}'
|
|
497
|
+
return 1
|
|
498
|
+
fi
|
|
499
|
+
|
|
500
|
+
if [[ ! -d "$plans_dir" ]]; then
|
|
501
|
+
echo '{"error": "Plans directory not found"}'
|
|
502
|
+
return 1
|
|
503
|
+
fi
|
|
504
|
+
|
|
505
|
+
# Create index directory if it doesn't exist
|
|
506
|
+
mkdir -p "$index_dir"
|
|
507
|
+
|
|
508
|
+
# Start building JSON
|
|
509
|
+
local plans_json="{"
|
|
510
|
+
local keywords_json="{"
|
|
511
|
+
local is_first_plan=true
|
|
512
|
+
local plan_count=0
|
|
513
|
+
|
|
514
|
+
# Associative array for keyword tracking
|
|
515
|
+
declare -A keyword_to_plans
|
|
516
|
+
|
|
517
|
+
# Process all plan files - OPTIMIZED: Single file read per plan
|
|
518
|
+
while IFS= read -r -d '' plan_file; do
|
|
519
|
+
local plan_name
|
|
520
|
+
plan_name=$(basename "$plan_file")
|
|
521
|
+
|
|
522
|
+
# Extract story ID from filename (STORY-NNN pattern) - no subshell
|
|
523
|
+
local story_id=""
|
|
524
|
+
[[ "$plan_name" =~ STORY-([0-9]+) ]] && story_id="STORY-${BASH_REMATCH[1]}"
|
|
525
|
+
|
|
526
|
+
# OPTIMIZATION: Read file content ONCE into memory
|
|
527
|
+
local file_content
|
|
528
|
+
file_content=$(cat "$plan_file" 2>/dev/null) || file_content=""
|
|
529
|
+
|
|
530
|
+
# Extract frontmatter from cached content
|
|
531
|
+
local status="" created=""
|
|
532
|
+
if [[ "$file_content" == "---"* ]]; then
|
|
533
|
+
local yaml_block
|
|
534
|
+
yaml_block=$(echo "$file_content" | awk 'BEGIN{f=0} /^---$/{f++; if(f==2) exit; next} f==1{print}')
|
|
535
|
+
status=$(echo "$yaml_block" | grep -E "^status:" | sed 's/^status:[[:space:]]*//' | tr -d '"' | tr -d "'" || echo "")
|
|
536
|
+
created=$(echo "$yaml_block" | grep -E "^created:" | sed 's/^created:[[:space:]]*//' | tr -d '"' | tr -d "'" || echo "")
|
|
537
|
+
fi
|
|
538
|
+
|
|
539
|
+
# Extract decision sections from cached content
|
|
540
|
+
local decision="" technical_approach=""
|
|
541
|
+
decision=$(echo "$file_content" | awk '/^## Decision$/{capture=1; next} /^## /{capture=0} capture{print}' | tr '\n' ' ' | sed 's/ */ /g;s/^ //;s/ $//' || echo "")
|
|
542
|
+
technical_approach=$(echo "$file_content" | awk '/^## Technical Approach$/{capture=1; next} /^## /{capture=0} capture{print}' | tr '\n' ' ' | sed 's/ */ /g;s/^ //;s/ $//' || echo "")
|
|
543
|
+
|
|
544
|
+
# Build full text from cached content
|
|
545
|
+
local full_text
|
|
546
|
+
full_text=$(echo "$file_content" | tr '\n' ' ' | sed 's/ */ /g' | head -c 500 || echo "")
|
|
547
|
+
local escaped_full_text escaped_decision escaped_technical
|
|
548
|
+
escaped_full_text=$(json_escape "$full_text")
|
|
549
|
+
escaped_decision=$(json_escape "$decision")
|
|
550
|
+
escaped_technical=$(json_escape "$technical_approach")
|
|
551
|
+
|
|
552
|
+
# Build keywords from cached content - use word list directly, skip common words
|
|
553
|
+
local content_words
|
|
554
|
+
content_words=$(echo "$file_content" | tr '[:upper:]' '[:lower:]' | grep -oE '[a-z]{4,}' | \
|
|
555
|
+
grep -vE '^(that|this|with|from|have|been|were|will|would|could|should|their|there|which|about)$' | \
|
|
556
|
+
sort -u | head -50 || echo "")
|
|
557
|
+
|
|
558
|
+
# Track keywords for this plan using helper
|
|
559
|
+
while IFS= read -r word; do
|
|
560
|
+
[[ -n "$word" ]] && add_keyword_mapping keyword_to_plans "$word" "$plan_name"
|
|
561
|
+
done <<< "$content_words"
|
|
562
|
+
|
|
563
|
+
# Add to plans JSON
|
|
564
|
+
if [[ "$is_first_plan" == "true" ]]; then
|
|
565
|
+
is_first_plan=false
|
|
566
|
+
else
|
|
567
|
+
plans_json+=", "
|
|
568
|
+
fi
|
|
569
|
+
|
|
570
|
+
plans_json+="\"$plan_name\": {\"story_id\": \"$story_id\", \"status\": \"$status\", \"created\": \"$created\", \"decision\": \"$escaped_decision\", \"technical_approach\": \"$escaped_technical\", \"full_text\": \"$escaped_full_text\"}"
|
|
571
|
+
|
|
572
|
+
((plan_count++))
|
|
573
|
+
done < <(find "$plans_dir" -name "*.md" -type f -print0 2>/dev/null)
|
|
574
|
+
|
|
575
|
+
plans_json+="}"
|
|
576
|
+
|
|
577
|
+
# Build keywords JSON
|
|
578
|
+
local is_first_keyword=true
|
|
579
|
+
for keyword in "${!keyword_to_plans[@]}"; do
|
|
580
|
+
if [[ "$is_first_keyword" == "true" ]]; then
|
|
581
|
+
is_first_keyword=false
|
|
582
|
+
else
|
|
583
|
+
keywords_json+=", "
|
|
584
|
+
fi
|
|
585
|
+
keywords_json+="\"$keyword\": [${keyword_to_plans[$keyword]}]"
|
|
586
|
+
done
|
|
587
|
+
keywords_json+="}"
|
|
588
|
+
|
|
589
|
+
# Combine into final index
|
|
590
|
+
local index_json="{\"plans\": $plans_json, \"keywords\": $keywords_json}"
|
|
591
|
+
|
|
592
|
+
# Write to file
|
|
593
|
+
echo "$index_json" > "$index_dir/searchable_index.json"
|
|
594
|
+
|
|
595
|
+
# Return success
|
|
596
|
+
printf '{"status": "success", "plan_count": %d, "index_path": "%s/searchable_index.json"}\n' \
|
|
597
|
+
"$plan_count" "$index_dir"
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
# =============================================================================
|
|
601
|
+
# STORY-232: Function 7: search_index
|
|
602
|
+
# AC#3: Search index for keyword matches in decision, technical approach, full text
|
|
603
|
+
# Usage: search_index "$index_dir" "$keyword"
|
|
604
|
+
# Returns: JSON with "query", "matches", "count" keys
|
|
605
|
+
# =============================================================================
|
|
606
|
+
search_index() {
|
|
607
|
+
local index_dir="${1:-}"
|
|
608
|
+
local keyword="${2:-}"
|
|
609
|
+
|
|
610
|
+
# Validate index_dir provided
|
|
611
|
+
if [[ -z "$index_dir" ]]; then
|
|
612
|
+
echo '{"error": "index_dir argument required", "query": "", "matches": [], "count": 0}'
|
|
613
|
+
return 1
|
|
614
|
+
fi
|
|
615
|
+
|
|
616
|
+
# Validate path doesn't contain traversal
|
|
617
|
+
if [[ "$index_dir" == *".."* ]]; then
|
|
618
|
+
echo '{"error": "Path traversal not allowed", "query": "", "matches": [], "count": 0}'
|
|
619
|
+
return 1
|
|
620
|
+
fi
|
|
621
|
+
|
|
622
|
+
local index_file="$index_dir/searchable_index.json"
|
|
623
|
+
|
|
624
|
+
if [[ ! -f "$index_file" ]]; then
|
|
625
|
+
echo '{"error": "Index not found", "query": "'"$keyword"'", "matches": [], "count": 0}'
|
|
626
|
+
return 1
|
|
627
|
+
fi
|
|
628
|
+
|
|
629
|
+
# Handle empty keyword
|
|
630
|
+
if [[ -z "$keyword" ]]; then
|
|
631
|
+
echo '{"error": "Empty query", "query": "", "matches": [], "count": 0}'
|
|
632
|
+
return 1
|
|
633
|
+
fi
|
|
634
|
+
|
|
635
|
+
local index_content
|
|
636
|
+
index_content=$(cat "$index_file")
|
|
637
|
+
|
|
638
|
+
# Convert keyword to lowercase for case-insensitive search
|
|
639
|
+
local keyword_lower
|
|
640
|
+
keyword_lower=$(echo "$keyword" | tr '[:upper:]' '[:lower:]')
|
|
641
|
+
|
|
642
|
+
# Sanitize regex metacharacters to prevent command injection
|
|
643
|
+
local sanitized_keyword
|
|
644
|
+
sanitized_keyword=$(printf '%s\n' "$keyword_lower" | sed 's/[.[\*^$()+?{|\\]/\\&/g')
|
|
645
|
+
|
|
646
|
+
# Collect matching plan files
|
|
647
|
+
declare -A matches_set
|
|
648
|
+
|
|
649
|
+
# Method 1: Check keyword index (pre-built keywords section)
|
|
650
|
+
# Look for "keyword": ["plan1.md", "plan2.md"] pattern
|
|
651
|
+
local keyword_matches
|
|
652
|
+
keyword_matches=$(echo "$index_content" | grep -oE "\"$sanitized_keyword\": *\[[^\]]*\]" | \
|
|
653
|
+
grep -oE '"STORY-[^"]+\.md"' | tr -d '"' || echo "")
|
|
654
|
+
|
|
655
|
+
while IFS= read -r match; do
|
|
656
|
+
if [[ -n "$match" ]]; then
|
|
657
|
+
matches_set["$match"]=1
|
|
658
|
+
fi
|
|
659
|
+
done <<< "$keyword_matches"
|
|
660
|
+
|
|
661
|
+
# Method 2: Search in full text content (case insensitive grep through entire file)
|
|
662
|
+
# Find plan names that contain the keyword in their content
|
|
663
|
+
local plan_names
|
|
664
|
+
plan_names=$(echo "$index_content" | grep -oE '"STORY-[^"]+\.md":' | tr -d '":' || echo "")
|
|
665
|
+
|
|
666
|
+
while IFS= read -r plan_name; do
|
|
667
|
+
if [[ -z "$plan_name" ]]; then continue; fi
|
|
668
|
+
|
|
669
|
+
# Use awk to extract the block for this plan and search for keyword
|
|
670
|
+
local found
|
|
671
|
+
found=$(echo "$index_content" | awk -v plan="$plan_name" -v kw="$keyword_lower" '
|
|
672
|
+
BEGIN { IGNORECASE=1; in_plan=0; content="" }
|
|
673
|
+
$0 ~ ("\"" plan "\":") { in_plan=1 }
|
|
674
|
+
in_plan { content = content $0 }
|
|
675
|
+
in_plan && /\}[,]?$/ && !/\{/ {
|
|
676
|
+
if (content ~ kw) print plan
|
|
677
|
+
in_plan=0; content=""
|
|
678
|
+
}
|
|
679
|
+
' || echo "")
|
|
680
|
+
|
|
681
|
+
if [[ -n "$found" ]]; then
|
|
682
|
+
matches_set["$plan_name"]=1
|
|
683
|
+
fi
|
|
684
|
+
done <<< "$plan_names"
|
|
685
|
+
|
|
686
|
+
# Build matches array
|
|
687
|
+
local matches_json="["
|
|
688
|
+
local is_first=true
|
|
689
|
+
local count=0
|
|
690
|
+
|
|
691
|
+
for match in "${!matches_set[@]}"; do
|
|
692
|
+
if [[ "$is_first" == "true" ]]; then
|
|
693
|
+
is_first=false
|
|
694
|
+
else
|
|
695
|
+
matches_json+=", "
|
|
696
|
+
fi
|
|
697
|
+
matches_json+="\"$match\""
|
|
698
|
+
((count++))
|
|
699
|
+
done
|
|
700
|
+
matches_json+="]"
|
|
701
|
+
|
|
702
|
+
# Return result
|
|
703
|
+
printf '{"query": "%s", "matches": %s, "count": %d}\n' "$keyword" "$matches_json" "$count"
|
|
704
|
+
}
|