@hustle-together/api-dev-tools 3.6.5 → 3.10.0

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 (72) hide show
  1. package/README.md +5599 -258
  2. package/bin/cli.js +395 -20
  3. package/commands/README.md +459 -71
  4. package/commands/hustle-api-continue.md +158 -0
  5. package/commands/{api-create.md → hustle-api-create.md} +35 -15
  6. package/commands/{api-env.md → hustle-api-env.md} +4 -4
  7. package/commands/{api-interview.md → hustle-api-interview.md} +1 -1
  8. package/commands/{api-research.md → hustle-api-research.md} +3 -3
  9. package/commands/hustle-api-sessions.md +149 -0
  10. package/commands/{api-status.md → hustle-api-status.md} +16 -16
  11. package/commands/{api-verify.md → hustle-api-verify.md} +2 -2
  12. package/commands/hustle-combine.md +763 -0
  13. package/commands/hustle-ui-create-page.md +933 -0
  14. package/commands/hustle-ui-create.md +825 -0
  15. package/hooks/api-workflow-check.py +545 -21
  16. package/hooks/cache-research.py +337 -0
  17. package/hooks/check-api-routes.py +168 -0
  18. package/hooks/check-playwright-setup.py +103 -0
  19. package/hooks/check-storybook-setup.py +81 -0
  20. package/hooks/detect-interruption.py +165 -0
  21. package/hooks/enforce-a11y-audit.py +202 -0
  22. package/hooks/enforce-brand-guide.py +241 -0
  23. package/hooks/enforce-documentation.py +60 -8
  24. package/hooks/enforce-freshness.py +184 -0
  25. package/hooks/enforce-page-components.py +186 -0
  26. package/hooks/enforce-page-data-schema.py +155 -0
  27. package/hooks/enforce-questions-sourced.py +146 -0
  28. package/hooks/enforce-schema-from-interview.py +248 -0
  29. package/hooks/enforce-ui-disambiguation.py +108 -0
  30. package/hooks/enforce-ui-interview.py +130 -0
  31. package/hooks/generate-manifest-entry.py +1161 -0
  32. package/hooks/session-logger.py +297 -0
  33. package/hooks/session-startup.py +160 -15
  34. package/hooks/track-scope-coverage.py +220 -0
  35. package/hooks/track-tool-use.py +81 -1
  36. package/hooks/update-api-showcase.py +149 -0
  37. package/hooks/update-registry.py +352 -0
  38. package/hooks/update-ui-showcase.py +212 -0
  39. package/package.json +8 -3
  40. package/templates/BRAND_GUIDE.md +299 -0
  41. package/templates/CLAUDE-SECTION.md +56 -24
  42. package/templates/SPEC.json +640 -0
  43. package/templates/api-dev-state.json +217 -161
  44. package/templates/api-showcase/_components/APICard.tsx +153 -0
  45. package/templates/api-showcase/_components/APIModal.tsx +375 -0
  46. package/templates/api-showcase/_components/APIShowcase.tsx +231 -0
  47. package/templates/api-showcase/_components/APITester.tsx +522 -0
  48. package/templates/api-showcase/page.tsx +41 -0
  49. package/templates/component/Component.stories.tsx +172 -0
  50. package/templates/component/Component.test.tsx +237 -0
  51. package/templates/component/Component.tsx +86 -0
  52. package/templates/component/Component.types.ts +55 -0
  53. package/templates/component/index.ts +15 -0
  54. package/templates/dev-tools/_components/DevToolsLanding.tsx +320 -0
  55. package/templates/dev-tools/page.tsx +10 -0
  56. package/templates/page/page.e2e.test.ts +218 -0
  57. package/templates/page/page.tsx +42 -0
  58. package/templates/performance-budgets.json +58 -0
  59. package/templates/registry.json +13 -0
  60. package/templates/settings.json +90 -0
  61. package/templates/shared/HeroHeader.tsx +261 -0
  62. package/templates/shared/index.ts +1 -0
  63. package/templates/ui-showcase/_components/PreviewCard.tsx +315 -0
  64. package/templates/ui-showcase/_components/PreviewModal.tsx +676 -0
  65. package/templates/ui-showcase/_components/UIShowcase.tsx +262 -0
  66. package/templates/ui-showcase/page.tsx +26 -0
  67. package/demo/hustle-together/blog/gemini-vs-claude-widgets.html +0 -959
  68. package/demo/hustle-together/blog/interview-driven-api-development.html +0 -1146
  69. package/demo/hustle-together/blog/tdd-for-ai.html +0 -982
  70. package/demo/hustle-together/index.html +0 -1312
  71. package/demo/workflow-demo-v3.5-backup.html +0 -5008
  72. package/demo/workflow-demo.html +0 -6202
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Hook: PreToolUse for AskUserQuestion
4
+ Purpose: Validate interview questions come from research, not templates
5
+
6
+ This hook ensures that questions asked during the interview phase are
7
+ generated from actual research findings, not generic template questions.
8
+
9
+ Added in v3.6.7 for question quality enforcement.
10
+
11
+ Returns:
12
+ - {"permissionDecision": "allow"} - Question is properly sourced
13
+ - {"permissionDecision": "allow", "message": "..."} - Allow with reminder
14
+ """
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
20
+
21
+
22
+ def get_active_endpoint(state):
23
+ """Get active endpoint - supports both old and new state formats."""
24
+ if "endpoints" in state and "active_endpoint" in state:
25
+ active = state.get("active_endpoint")
26
+ if active and active in state["endpoints"]:
27
+ return active, state["endpoints"][active]
28
+ return None, None
29
+
30
+ endpoint = state.get("endpoint")
31
+ if endpoint:
32
+ return endpoint, state
33
+
34
+ return None, None
35
+
36
+
37
+ def get_research_keywords(state, endpoint_data):
38
+ """Extract keywords from research that should appear in questions."""
39
+ keywords = set()
40
+
41
+ # From research queries
42
+ for query in state.get("research_queries", []):
43
+ q = query.get("query", "")
44
+ # Extract meaningful words (length > 3)
45
+ words = [w.lower() for w in q.split() if len(w) > 3]
46
+ keywords.update(words)
47
+
48
+ # From initial research sources
49
+ initial = endpoint_data.get("phases", {}).get("research_initial", {})
50
+ for src in initial.get("sources", []):
51
+ if isinstance(src, dict):
52
+ summary = src.get("summary", "")
53
+ words = [w.lower() for w in summary.split() if len(w) > 3]
54
+ keywords.update(words)
55
+
56
+ # From deep research sources
57
+ deep = endpoint_data.get("phases", {}).get("research_deep", {})
58
+ for src in deep.get("sources", []):
59
+ if isinstance(src, dict):
60
+ summary = src.get("summary", "")
61
+ words = [w.lower() for w in summary.split() if len(w) > 3]
62
+ keywords.update(words)
63
+
64
+ return keywords
65
+
66
+
67
+ def main():
68
+ try:
69
+ input_data = json.load(sys.stdin)
70
+ except json.JSONDecodeError:
71
+ print(json.dumps({"permissionDecision": "allow"}))
72
+ sys.exit(0)
73
+
74
+ tool_name = input_data.get("tool_name", "")
75
+ tool_input = input_data.get("tool_input", {})
76
+
77
+ if tool_name != "AskUserQuestion":
78
+ print(json.dumps({"permissionDecision": "allow"}))
79
+ sys.exit(0)
80
+
81
+ if not STATE_FILE.exists():
82
+ print(json.dumps({"permissionDecision": "allow"}))
83
+ sys.exit(0)
84
+
85
+ try:
86
+ state = json.loads(STATE_FILE.read_text())
87
+ except json.JSONDecodeError:
88
+ print(json.dumps({"permissionDecision": "allow"}))
89
+ sys.exit(0)
90
+
91
+ endpoint, endpoint_data = get_active_endpoint(state)
92
+ if not endpoint or not endpoint_data:
93
+ print(json.dumps({"permissionDecision": "allow"}))
94
+ sys.exit(0)
95
+
96
+ # Only enforce during interview phase
97
+ interview = endpoint_data.get("phases", {}).get("interview", {})
98
+ if interview.get("status") != "in_progress":
99
+ print(json.dumps({"permissionDecision": "allow"}))
100
+ sys.exit(0)
101
+
102
+ # Check if research has been done
103
+ initial = endpoint_data.get("phases", {}).get("research_initial", {})
104
+ if initial.get("status") != "complete":
105
+ # Allow question but remind to do research first
106
+ print(json.dumps({
107
+ "permissionDecision": "allow",
108
+ "message": "REMINDER: Initial research (Phase 3) should be complete before interview. Questions should be generated FROM research findings."
109
+ }))
110
+ sys.exit(0)
111
+
112
+ # Get the question being asked
113
+ question = tool_input.get("question", "")
114
+
115
+ # Get research keywords
116
+ keywords = get_research_keywords(state, endpoint_data)
117
+
118
+ # Check if question contains any research-derived terms
119
+ question_lower = question.lower()
120
+ found_keywords = [k for k in keywords if k in question_lower]
121
+
122
+ if not found_keywords and len(keywords) > 5:
123
+ # No research keywords found - this might be a generic question
124
+ print(json.dumps({
125
+ "permissionDecision": "allow",
126
+ "message": f"""NOTE: This question doesn't appear to reference terms discovered in research.
127
+
128
+ Research-derived terms include: {', '.join(list(keywords)[:10])}...
129
+
130
+ BEST PRACTICE: Interview questions should be generated FROM research findings.
131
+ Example: "I discovered the API supports [feature]. Do you want to implement this?"
132
+
133
+ Proceeding anyway, but consider revising the question."""
134
+ }))
135
+ sys.exit(0)
136
+
137
+ # Question looks good
138
+ print(json.dumps({
139
+ "permissionDecision": "allow",
140
+ "message": f"Question references research terms: {', '.join(found_keywords[:5])}"
141
+ }))
142
+ sys.exit(0)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Hook: PreToolUse for Write/Edit on schema files
4
+ Purpose: Validate schema fields match interview decisions
5
+
6
+ This hook ensures that when writing Zod schema files, the fields
7
+ match what the user selected during the interview phase.
8
+
9
+ Added in v3.6.7 for schema-interview consistency enforcement.
10
+
11
+ Returns:
12
+ - {"permissionDecision": "allow"} - Schema matches interview
13
+ - {"permissionDecision": "allow", "message": "..."} - Allow with warning
14
+ - {"permissionDecision": "deny", "reason": "..."} - Block with explanation
15
+ """
16
+ import json
17
+ import sys
18
+ import re
19
+ from pathlib import Path
20
+
21
+ STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
22
+
23
+
24
+ def get_active_endpoint(state):
25
+ """Get active endpoint - supports both old and new state formats."""
26
+ if "endpoints" in state and "active_endpoint" in state:
27
+ active = state.get("active_endpoint")
28
+ if active and active in state["endpoints"]:
29
+ return active, state["endpoints"][active]
30
+ return None, None
31
+
32
+ endpoint = state.get("endpoint")
33
+ if endpoint:
34
+ return endpoint, state
35
+
36
+ return None, None
37
+
38
+
39
+ def extract_schema_fields_from_content(content):
40
+ """Extract field names from Zod schema content."""
41
+ fields = set()
42
+
43
+ # Match Zod object field definitions
44
+ # Patterns like: fieldName: z.string(), fieldName: z.number().optional()
45
+ field_pattern = r'(\w+):\s*z\.\w+\('
46
+
47
+ for match in re.finditer(field_pattern, content):
48
+ field_name = match.group(1)
49
+ # Skip common non-field names
50
+ if field_name not in {'z', 'const', 'export', 'type', 'interface'}:
51
+ fields.add(field_name.lower())
52
+
53
+ return fields
54
+
55
+
56
+ def extract_interview_approved_fields(endpoint_data):
57
+ """Extract field names that were approved during interview."""
58
+ approved_fields = set()
59
+
60
+ interview = endpoint_data.get("phases", {}).get("interview", {})
61
+ decisions = interview.get("decisions", {})
62
+ questions = interview.get("questions", [])
63
+
64
+ # Extract from decisions
65
+ for key, value in decisions.items():
66
+ # Decision keys often match field names
67
+ approved_fields.add(key.lower())
68
+
69
+ # Values might be lists of approved options
70
+ if isinstance(value, list):
71
+ for v in value:
72
+ if isinstance(v, str):
73
+ approved_fields.add(v.lower())
74
+ elif isinstance(value, str):
75
+ approved_fields.add(value.lower())
76
+
77
+ # Extract from question text (look for parameters mentioned)
78
+ for q in questions:
79
+ if isinstance(q, dict):
80
+ q_text = q.get("question", "") + " " + q.get("answer", "")
81
+ else:
82
+ q_text = str(q)
83
+
84
+ # Look for parameter-like words
85
+ param_pattern = r'\b(param|field|property|attribute)[:=\s]+["\']?(\w+)["\']?'
86
+ for match in re.finditer(param_pattern, q_text, re.IGNORECASE):
87
+ approved_fields.add(match.group(2).lower())
88
+
89
+ # Also extract snake_case and camelCase words that look like fields
90
+ field_like = re.findall(r'\b([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b', q_text, re.IGNORECASE)
91
+ for f in field_like:
92
+ approved_fields.add(f.lower())
93
+
94
+ field_like = re.findall(r'\b([a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)+)\b', q_text)
95
+ for f in field_like:
96
+ # Convert camelCase to lowercase for comparison
97
+ approved_fields.add(f.lower())
98
+
99
+ # Extract from scope if available
100
+ scope = endpoint_data.get("scope", {})
101
+ for feature in scope.get("implemented_features", []):
102
+ if isinstance(feature, dict):
103
+ approved_fields.add(feature.get("name", "").lower())
104
+ else:
105
+ approved_fields.add(str(feature).lower())
106
+
107
+ return approved_fields
108
+
109
+
110
+ def main():
111
+ try:
112
+ input_data = json.load(sys.stdin)
113
+ except json.JSONDecodeError:
114
+ print(json.dumps({"permissionDecision": "allow"}))
115
+ sys.exit(0)
116
+
117
+ tool_name = input_data.get("tool_name", "")
118
+ tool_input = input_data.get("tool_input", {})
119
+
120
+ # Only check Write and Edit tools
121
+ if tool_name not in ["Write", "Edit"]:
122
+ print(json.dumps({"permissionDecision": "allow"}))
123
+ sys.exit(0)
124
+
125
+ # Check if writing to a schema file
126
+ file_path = tool_input.get("file_path", "")
127
+
128
+ # Detect schema files by path patterns
129
+ is_schema_file = any([
130
+ "/schemas/" in file_path,
131
+ "schema.ts" in file_path.lower(),
132
+ "schemas.ts" in file_path.lower(),
133
+ ".schema.ts" in file_path.lower(),
134
+ ])
135
+
136
+ if not is_schema_file:
137
+ print(json.dumps({"permissionDecision": "allow"}))
138
+ sys.exit(0)
139
+
140
+ # Load state
141
+ if not STATE_FILE.exists():
142
+ print(json.dumps({"permissionDecision": "allow"}))
143
+ sys.exit(0)
144
+
145
+ try:
146
+ state = json.loads(STATE_FILE.read_text())
147
+ except json.JSONDecodeError:
148
+ print(json.dumps({"permissionDecision": "allow"}))
149
+ sys.exit(0)
150
+
151
+ endpoint, endpoint_data = get_active_endpoint(state)
152
+ if not endpoint or not endpoint_data:
153
+ print(json.dumps({"permissionDecision": "allow"}))
154
+ sys.exit(0)
155
+
156
+ # Check if interview phase is complete
157
+ interview = endpoint_data.get("phases", {}).get("interview", {})
158
+ if interview.get("status") != "complete":
159
+ # Interview not done yet - allow but warn
160
+ print(json.dumps({
161
+ "permissionDecision": "allow",
162
+ "message": "WARNING: Writing schema before interview is complete. Schema fields should be derived from interview decisions."
163
+ }))
164
+ sys.exit(0)
165
+
166
+ # Get schema content being written
167
+ if tool_name == "Write":
168
+ schema_content = tool_input.get("content", "")
169
+ else: # Edit
170
+ new_string = tool_input.get("new_string", "")
171
+ schema_content = new_string
172
+
173
+ if not schema_content:
174
+ print(json.dumps({"permissionDecision": "allow"}))
175
+ sys.exit(0)
176
+
177
+ # Extract fields from schema
178
+ schema_fields = extract_schema_fields_from_content(schema_content)
179
+
180
+ if not schema_fields:
181
+ print(json.dumps({"permissionDecision": "allow"}))
182
+ sys.exit(0)
183
+
184
+ # Extract approved fields from interview
185
+ approved_fields = extract_interview_approved_fields(endpoint_data)
186
+
187
+ if not approved_fields:
188
+ # No interview data to compare against
189
+ print(json.dumps({
190
+ "permissionDecision": "allow",
191
+ "message": "NOTE: No interview decisions found to validate schema against."
192
+ }))
193
+ sys.exit(0)
194
+
195
+ # Find fields in schema that weren't discussed in interview
196
+ # Use fuzzy matching - if any part of field name matches approved
197
+ unmatched_fields = []
198
+ for field in schema_fields:
199
+ matched = False
200
+ for approved in approved_fields:
201
+ # Check if field contains or is contained by approved field
202
+ if field in approved or approved in field:
203
+ matched = True
204
+ break
205
+ # Check word overlap
206
+ field_words = set(re.split(r'[_\s]', field))
207
+ approved_words = set(re.split(r'[_\s]', approved))
208
+ if field_words & approved_words:
209
+ matched = True
210
+ break
211
+
212
+ if not matched:
213
+ unmatched_fields.append(field)
214
+
215
+ # Common fields that are always okay
216
+ common_fields = {'id', 'createdat', 'updatedat', 'error', 'message', 'success', 'data', 'status', 'result'}
217
+ unmatched_fields = [f for f in unmatched_fields if f not in common_fields]
218
+
219
+ if unmatched_fields:
220
+ # Found fields not in interview - warn but allow
221
+ print(json.dumps({
222
+ "permissionDecision": "allow",
223
+ "message": f"""SCHEMA VALIDATION NOTE:
224
+
225
+ The following schema fields were not explicitly discussed in the interview:
226
+ {', '.join(unmatched_fields[:5])}{'...' if len(unmatched_fields) > 5 else ''}
227
+
228
+ Interview-approved terms: {', '.join(list(approved_fields)[:10])}{'...' if len(approved_fields) > 10 else ''}
229
+
230
+ This is allowed, but consider:
231
+ 1. Did research discover these fields?
232
+ 2. Should the user be asked about these?
233
+ 3. Are these derived from approved fields?
234
+
235
+ Proceeding with schema write."""
236
+ }))
237
+ sys.exit(0)
238
+
239
+ # All fields match interview
240
+ print(json.dumps({
241
+ "permissionDecision": "allow",
242
+ "message": f"Schema fields validated against interview: {', '.join(list(schema_fields)[:5])}"
243
+ }))
244
+ sys.exit(0)
245
+
246
+
247
+ if __name__ == "__main__":
248
+ main()
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Hook: PreToolUse for Write/Edit
4
+ Purpose: Block UI implementation until component/page type is clarified (Phase 1)
5
+
6
+ This hook ensures that Phase 1 (Disambiguation) is complete before any
7
+ component or page files are written. It checks that:
8
+ - Component type (atom/molecule/organism) is specified for components
9
+ - Page type (landing/dashboard/form/list) is specified for pages
10
+
11
+ Version: 3.9.0
12
+
13
+ Returns:
14
+ - {"continue": true} - If disambiguation is complete or not a UI workflow
15
+ - {"continue": false, "reason": "..."} - If disambiguation is incomplete
16
+ """
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ # State file is in .claude/ directory (sibling to hooks/)
22
+ STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
23
+
24
+
25
+ def main():
26
+ # Read hook input from stdin
27
+ try:
28
+ input_data = json.load(sys.stdin)
29
+ except json.JSONDecodeError:
30
+ print(json.dumps({"continue": True}))
31
+ sys.exit(0)
32
+
33
+ tool_name = input_data.get("tool_name", "")
34
+ tool_input = input_data.get("tool_input", {})
35
+
36
+ # Only check Write/Edit operations
37
+ if tool_name not in ["Write", "Edit"]:
38
+ print(json.dumps({"continue": True}))
39
+ sys.exit(0)
40
+
41
+ # Check if targeting component or page files
42
+ file_path = tool_input.get("file_path", "")
43
+ is_component = "/components/" in file_path and file_path.endswith(".tsx")
44
+ is_page = "/app/" in file_path and "page.tsx" in file_path
45
+
46
+ if not is_component and not is_page:
47
+ print(json.dumps({"continue": True}))
48
+ sys.exit(0)
49
+
50
+ # Check if state file exists
51
+ if not STATE_FILE.exists():
52
+ print(json.dumps({"continue": True}))
53
+ sys.exit(0)
54
+
55
+ # Load state
56
+ try:
57
+ state = json.loads(STATE_FILE.read_text())
58
+ except json.JSONDecodeError:
59
+ print(json.dumps({"continue": True}))
60
+ sys.exit(0)
61
+
62
+ workflow = state.get("workflow", "")
63
+
64
+ # Only enforce for UI workflows
65
+ if workflow not in ["ui-create-component", "ui-create-page"]:
66
+ print(json.dumps({"continue": True}))
67
+ sys.exit(0)
68
+
69
+ # Get UI config
70
+ ui_config = state.get("ui_config", {})
71
+
72
+ # Check disambiguation for components
73
+ if workflow == "ui-create-component":
74
+ component_type = ui_config.get("component_type", "")
75
+ if not component_type:
76
+ print(json.dumps({
77
+ "continue": False,
78
+ "reason": (
79
+ "Phase 1 (Disambiguation) incomplete.\n\n"
80
+ "Before creating this component, you must clarify:\n"
81
+ "- Is this an Atom, Molecule, or Organism?\n\n"
82
+ "Please complete the disambiguation phase first."
83
+ )
84
+ }))
85
+ sys.exit(0)
86
+
87
+ # Check disambiguation for pages
88
+ if workflow == "ui-create-page":
89
+ page_type = ui_config.get("page_type", "")
90
+ if not page_type:
91
+ print(json.dumps({
92
+ "continue": False,
93
+ "reason": (
94
+ "Phase 1 (Disambiguation) incomplete.\n\n"
95
+ "Before creating this page, you must clarify:\n"
96
+ "- Is this a Landing, Dashboard, Form, or List page?\n\n"
97
+ "Please complete the disambiguation phase first."
98
+ )
99
+ }))
100
+ sys.exit(0)
101
+
102
+ # Disambiguation complete
103
+ print(json.dumps({"continue": True}))
104
+ sys.exit(0)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Hook: PreToolUse for Write/Edit
4
+ Purpose: Inject UI interview decisions during component/page implementation
5
+
6
+ This hook injects the user's interview answers (variants, accessibility level,
7
+ component dependencies, etc.) when Claude writes implementation code.
8
+ This ensures the implementation matches what the user specified.
9
+
10
+ Version: 3.9.0
11
+
12
+ Returns:
13
+ - {"continue": true} - Always continues
14
+ - May include "notify" with key decisions summary
15
+ """
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ # State file is in .claude/ directory (sibling to hooks/)
21
+ STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
22
+
23
+
24
+ def format_decisions(decisions):
25
+ """Format interview decisions for display."""
26
+ formatted = []
27
+
28
+ for key, value in decisions.items():
29
+ if isinstance(value, dict):
30
+ # Extract value from nested structure
31
+ display_value = value.get("value", value.get("response", str(value)))
32
+ else:
33
+ display_value = value
34
+
35
+ # Format for display
36
+ if isinstance(display_value, list):
37
+ display_value = ", ".join(str(v) for v in display_value)
38
+ elif isinstance(display_value, bool):
39
+ display_value = "Yes" if display_value else "No"
40
+
41
+ formatted.append(f"{key}: {display_value}")
42
+
43
+ return formatted
44
+
45
+
46
+ def main():
47
+ # Read hook input from stdin
48
+ try:
49
+ input_data = json.load(sys.stdin)
50
+ except json.JSONDecodeError:
51
+ print(json.dumps({"continue": True}))
52
+ sys.exit(0)
53
+
54
+ tool_name = input_data.get("tool_name", "")
55
+ tool_input = input_data.get("tool_input", {})
56
+
57
+ # Only check Write/Edit operations
58
+ if tool_name not in ["Write", "Edit"]:
59
+ print(json.dumps({"continue": True}))
60
+ sys.exit(0)
61
+
62
+ # Check if targeting component or page files
63
+ file_path = tool_input.get("file_path", "")
64
+ is_component = "/components/" in file_path and file_path.endswith(".tsx")
65
+ is_page = "/app/" in file_path and "page.tsx" in file_path
66
+
67
+ if not is_component and not is_page:
68
+ print(json.dumps({"continue": True}))
69
+ sys.exit(0)
70
+
71
+ # Check if state file exists
72
+ if not STATE_FILE.exists():
73
+ print(json.dumps({"continue": True}))
74
+ sys.exit(0)
75
+
76
+ # Load state
77
+ try:
78
+ state = json.loads(STATE_FILE.read_text())
79
+ except json.JSONDecodeError:
80
+ print(json.dumps({"continue": True}))
81
+ sys.exit(0)
82
+
83
+ workflow = state.get("workflow", "")
84
+
85
+ # Only apply for UI workflows
86
+ if workflow not in ["ui-create-component", "ui-create-page"]:
87
+ print(json.dumps({"continue": True}))
88
+ sys.exit(0)
89
+
90
+ # Get interview decisions
91
+ # Try elements format first, then fall back to phases format
92
+ active_element = state.get("active_element", "")
93
+ elements = state.get("elements", {})
94
+
95
+ if active_element and active_element in elements:
96
+ phases = elements[active_element].get("phases", {})
97
+ else:
98
+ phases = state.get("phases", {})
99
+
100
+ interview = phases.get("interview", {})
101
+ decisions = interview.get("decisions", {})
102
+
103
+ if not decisions:
104
+ print(json.dumps({"continue": True}))
105
+ sys.exit(0)
106
+
107
+ # Format key decisions for notification
108
+ formatted = format_decisions(decisions)
109
+
110
+ # Limit to most important decisions
111
+ key_decisions = []
112
+ priority_keys = ["component_type", "page_type", "variants", "accessibility", "design_system", "data_fetching"]
113
+
114
+ for key in priority_keys:
115
+ for f in formatted:
116
+ if f.lower().startswith(key.replace("_", " ")):
117
+ key_decisions.append(f)
118
+ break
119
+
120
+ if key_decisions:
121
+ notify_msg = "Interview decisions: " + " | ".join(key_decisions[:4])
122
+ print(json.dumps({"continue": True, "notify": notify_msg}))
123
+ else:
124
+ print(json.dumps({"continue": True}))
125
+
126
+ sys.exit(0)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()