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.
Files changed (133) hide show
  1. package/CLAUDE.md +120 -0
  2. package/bin/devforgeai.js +0 -0
  3. package/package.json +9 -1
  4. package/src/CLAUDE.md +699 -0
  5. package/src/claude/hooks/phase-completion-gate.sh +0 -0
  6. package/src/claude/scripts/README.md +396 -0
  7. package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
  8. package/src/claude/scripts/check-hooks-fast.sh +70 -0
  9. package/src/claude/scripts/devforgeai-validate +6 -0
  10. package/src/claude/scripts/devforgeai_cli/README.md +531 -0
  11. package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
  12. package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
  13. package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
  14. package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
  15. package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
  16. package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
  17. package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
  18. package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
  19. package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
  20. package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
  21. package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
  22. package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
  23. package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
  24. package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
  25. package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
  26. package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
  27. package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
  28. package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
  29. package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
  30. package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
  31. package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
  32. package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
  33. package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
  34. package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
  35. package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
  36. package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
  37. package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
  38. package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
  39. package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
  40. package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
  41. package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
  42. package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
  43. package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
  44. package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
  45. package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
  46. package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
  47. package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
  48. package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
  49. package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
  50. package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
  51. package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
  52. package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
  53. package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
  54. package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
  55. package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
  56. package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
  57. package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
  58. package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
  59. package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
  60. package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
  61. package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
  62. package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
  63. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
  64. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
  65. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
  66. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
  67. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
  68. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
  69. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
  70. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
  71. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
  72. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
  73. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
  74. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
  75. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
  76. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
  77. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
  78. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
  79. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
  80. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
  81. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
  82. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
  83. package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
  84. package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
  85. package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
  86. package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
  87. package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
  88. package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
  89. package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
  90. package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
  91. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
  92. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
  93. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
  94. package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
  95. package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
  96. package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
  97. package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
  98. package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
  99. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
  100. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
  101. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
  102. package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
  103. package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
  104. package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
  105. package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
  106. package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
  107. package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
  108. package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
  109. package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
  110. package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
  111. package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
  112. package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
  113. package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
  114. package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
  115. package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
  116. package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
  117. package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
  118. package/src/claude/scripts/install_hooks.sh +186 -0
  119. package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
  120. package/src/claude/scripts/migrate-ac-headers.sh +122 -0
  121. package/src/claude/scripts/plan_file_kb.sh +704 -0
  122. package/src/claude/scripts/requirements.txt +8 -0
  123. package/src/claude/scripts/session_catalog.sh +543 -0
  124. package/src/claude/scripts/setup.py +55 -0
  125. package/src/claude/scripts/start-devforgeai.sh +16 -0
  126. package/src/claude/scripts/statusline.sh +27 -0
  127. package/src/claude/scripts/validate_deferrals.py +344 -0
  128. package/src/claude/skills/designing-systems/scripts/__pycache__/detect_anti_patterns.cpython-312.pyc +0 -0
  129. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_all_context.cpython-312.pyc +0 -0
  130. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_architecture.cpython-312.pyc +0 -0
  131. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_dependencies.cpython-312.pyc +0 -0
  132. package/src/claude/skills/devforgeai-story-creation/scripts/__pycache__/migrate_story_v1_to_v2.cpython-312.pyc +0 -0
  133. 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
+ }