elliot-stack 1.0.29 → 1.0.33

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 (128) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -0
  3. package/bin/install.cjs +981 -950
  4. package/hooks/repo-search-nudge.js +32 -32
  5. package/package.json +1 -1
  6. package/skills/estack-active-learning-tutor/SKILL.md +339 -339
  7. package/skills/estack-better-title/SKILL.md +64 -64
  8. package/skills/estack-better-title/scripts/rename.sh +55 -55
  9. package/skills/estack-chris-voss/SKILL.md +80 -80
  10. package/skills/estack-chris-voss/references/elliot-notes.md +120 -120
  11. package/skills/estack-chris-voss/references/voss-principles.md +210 -210
  12. package/skills/estack-customer-discovery/SKILL.md +60 -60
  13. package/skills/estack-flight-planner/SKILL.md +332 -332
  14. package/skills/estack-flight-planner/references/config_schema.md +156 -156
  15. package/skills/estack-flight-planner/references/flight_history_schema.md +97 -97
  16. package/skills/estack-flight-planner/references/shuttle_schedules.md +98 -98
  17. package/skills/estack-flight-planner/scripts/check_setup.sh +89 -89
  18. package/skills/estack-flight-planner/scripts/fetch_flights.py +99 -99
  19. package/skills/estack-flight-planner/scripts/filter_flights.py +265 -265
  20. package/skills/estack-flight-planner/scripts/pair_shuttles.py +173 -173
  21. package/skills/estack-github-issue-tracker/SKILL.md +322 -322
  22. package/skills/estack-github-issue-tracker/bin/tracker-tools.cjs +1358 -1358
  23. package/skills/estack-github-issue-tracker/references/gh-cli-patterns.md +124 -124
  24. package/skills/estack-github-issue-tracker/references/result-file-schema.md +156 -156
  25. package/skills/estack-github-issue-tracker/references/tracker-schema.md +96 -96
  26. package/skills/estack-github-issue-tracker/tracker-template.md +58 -58
  27. package/skills/estack-leadership-coach/SKILL.md +235 -0
  28. package/skills/estack-leadership-coach/adding-references.md +280 -0
  29. package/skills/estack-leadership-coach/frameworks/delegation/flows/post-mortem.md +120 -0
  30. package/skills/estack-leadership-coach/frameworks/delegation/flows/pre-delegation.md +138 -0
  31. package/skills/estack-leadership-coach/frameworks/delegation/phases/1-intake.md +145 -0
  32. package/skills/estack-leadership-coach/frameworks/delegation/phases/2-trm-assessment.md +119 -0
  33. package/skills/estack-leadership-coach/frameworks/delegation/phases/3-enrollment.md +132 -0
  34. package/skills/estack-leadership-coach/frameworks/delegation/phases/4-build-brief.md +171 -0
  35. package/skills/estack-leadership-coach/frameworks/delegation/phases/5-monitoring.md +134 -0
  36. package/skills/estack-leadership-coach/frameworks/delegation/phases/6-reverse-delegation.md +118 -0
  37. package/skills/estack-leadership-coach/frameworks/delegation/phases/7-diagnose.md +200 -0
  38. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__deci-olafsen-ryan-2017-self-determination-theory-in-work-organizations.md +1881 -0
  39. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__gagne-deci-2005-self-determination-theory-and-work-motivation.md +2058 -0
  40. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__selfdeterminationtheory-org-theory-overview-page.md +61 -0
  41. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-3-key-insights-into-the-global-workplace-2024.md +57 -0
  42. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-managers-account-for-70-percent-of-variance-in-employee-engagement-2015.md +40 -0
  43. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-global-data-summary.md +73 -0
  44. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-report-landing.md +42 -0
  45. package/skills/estack-leadership-coach/references/.source-files/hormozi-leila_4-stages__leila-hormozi-the-art-of-delegation-blog-post.md +91 -0
  46. package/skills/estack-leadership-coach/references/.source-files/oncken-wass_monkeys-hbr-1974__oncken-wass-management-time-whos-got-the-monkey-hbr-classic-1974.md +969 -0
  47. package/skills/estack-leadership-coach/references/.source-files/sanchez_main-street-millionaire__codie-sanchez-afford-anything-podcast-ep-565-show-notes.md +89 -0
  48. package/skills/estack-leadership-coach/references/.source-files/sullivan_who-not-how__dan-sullivan-impact-filter-tool-and-guide-booklet.md +565 -0
  49. package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-lewis-howes-school-of-greatness-ep-1231-show-notes.md +122 -0
  50. package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-roger-dooley-cues-interview.md +194 -0
  51. package/skills/estack-leadership-coach/references/deci-ryan_self-determination-theory.md +166 -0
  52. package/skills/estack-leadership-coach/references/doerr_measure-what-matters.md +154 -0
  53. package/skills/estack-leadership-coach/references/ferriss_4hww.md +189 -0
  54. package/skills/estack-leadership-coach/references/gallup_engagement-research.md +105 -0
  55. package/skills/estack-leadership-coach/references/gerber_e-myth-revisited.md +118 -0
  56. package/skills/estack-leadership-coach/references/grove_high-output-management.md +95 -0
  57. package/skills/estack-leadership-coach/references/hormozi-alex_followthrough.md +152 -0
  58. package/skills/estack-leadership-coach/references/hormozi-leila_4-stages.md +146 -0
  59. package/skills/estack-leadership-coach/references/oncken-wass_monkeys-hbr-1974.md +128 -0
  60. package/skills/estack-leadership-coach/references/sanchez_main-street-millionaire.md +196 -0
  61. package/skills/estack-leadership-coach/references/sullivan_who-not-how.md +137 -0
  62. package/skills/estack-leadership-coach/references/van-edwards_cues.md +189 -0
  63. package/skills/estack-migrate-claude-session-history/SKILL.md +226 -0
  64. package/skills/estack-migrate-claude-session-history/references/path-encoding.md +55 -0
  65. package/skills/estack-migrate-claude-session-history/references/troubleshooting.md +96 -0
  66. package/skills/estack-migrate-claude-session-history/scripts/migrate-claude-history.js +1123 -0
  67. package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +48 -0
  68. package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +326 -0
  69. package/skills/estack-migrate-claude-session-history/scripts/validate-migration.py +493 -0
  70. package/skills/estack-pdf-to-md/SKILL.md +180 -0
  71. package/skills/estack-pdf-to-md/scripts/pdf_to_md.py +596 -0
  72. package/skills/estack-productivity-prioritization-coach/SKILL.md +124 -0
  73. package/skills/estack-productivity-prioritization-coach/sources/01-tony-robbins-rpm.md +39 -0
  74. package/skills/estack-productivity-prioritization-coach/sources/02-justin-sung-task-prioritization.md +34 -0
  75. package/skills/estack-prompt-builder-coach/SKILL.md +81 -81
  76. package/skills/estack-prompt-builder-coach/definition-of-done-generator.md +42 -42
  77. package/skills/estack-prompt-builder-coach/prompt-builder.md +37 -37
  78. package/skills/estack-prompt-builder-coach/task-shaper.md +36 -36
  79. package/skills/estack-prompt-builder-coach/vague-ask-auditor.md +37 -37
  80. package/skills/estack-read-claude-session-history/SKILL.md +204 -204
  81. package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -126
  82. package/skills/estack-read-claude-session-history/references/modes.md +423 -423
  83. package/skills/estack-read-claude-session-history/references/recipes.md +271 -271
  84. package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -1
  85. package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -460
  86. package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -234
  87. package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -179
  88. package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -88
  89. package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -144
  90. package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1776 -1776
  91. package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -40
  92. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -20
  93. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -4
  94. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -2
  95. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -9
  96. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -7
  97. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -3
  98. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -3
  99. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -5
  100. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -2
  101. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -8
  102. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -2
  103. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -2
  104. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -2
  105. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -2
  106. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -1
  107. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -4
  108. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -6
  109. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -5
  110. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -10
  111. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +2 -2
  112. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -2
  113. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -3
  114. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -5
  115. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -2
  116. package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -56
  117. package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -239
  118. package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -201
  119. package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -199
  120. package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -195
  121. package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -133
  122. package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -78
  123. package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -43
  124. package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +179 -179
  125. package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -212
  126. package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -80
  127. package/skills/estack-repo-search/SKILL.md +65 -65
  128. package/skills/estack-vscode-file-recovery/SKILL.md +188 -0
@@ -1,265 +1,265 @@
1
- """
2
- Filter and rank flights from saved SerpAPI JSON files.
3
-
4
- Reads JSON files written by fetch_flights.py, applies filters (price, time
5
- window, route, airlines, nonstop), ranks results, and outputs a JSON list to
6
- stdout. Filters can be marked "soft" via --soft-filters — soft filters include
7
- non-matching flights but flag them with `soft_filter_violations` and rank them
8
- lower.
9
-
10
- With --cluster-analysis, prints a constraint-impact report instead of
11
- filtering: shows which constraint(s) eliminated which flight counts, plus a
12
- price distribution. The skill uses this to propose specific relaxations when
13
- hard filters return zero results.
14
-
15
- Usage:
16
- python filter_flights.py --json-dir /tmp/estack-flight-planner/ --max-price 200 --from IND --to EWR
17
- python filter_flights.py --json-dir /tmp/estack-flight-planner/ --max-price 200 \\
18
- --soft-filters max-price,time-priority
19
- python filter_flights.py --json-dir /tmp/estack-flight-planner/ --cluster-analysis
20
-
21
- All filter args (--max-price, --time-priority, --from, --to, --airlines, --nonstop)
22
- are required for normal filter mode (no defaults — caller passes config values).
23
- Cluster-analysis mode echoes back impact of each provided filter.
24
- """
25
- import argparse
26
- import glob
27
- import json
28
- import sys
29
- import tempfile
30
- from pathlib import Path
31
-
32
-
33
- SOFT_FILTER_NAMES = {"max-price", "time-priority", "airlines", "nonstop", "route"}
34
-
35
-
36
- def parse_band(b: str):
37
- a, c = b.split("-")
38
- return a.strip(), c.strip()
39
-
40
-
41
- def in_band(t: str, lo: str, hi: str) -> bool:
42
- return lo <= t <= hi
43
-
44
-
45
- def parse_set(csv: str):
46
- if not csv:
47
- return set()
48
- return {x.strip() for x in csv.split(",") if x.strip()}
49
-
50
-
51
- def parse_bands(csv: str):
52
- if not csv:
53
- return []
54
- return [parse_band(b) for b in csv.split(",") if b.strip()]
55
-
56
-
57
- def load_flights(json_dir: Path):
58
- flights = []
59
- for fname in sorted(glob.glob(str(json_dir / "*.json"))):
60
- parts = Path(fname).stem.split("_")
61
- if len(parts) < 3:
62
- continue
63
- dep, arr = parts[0], parts[1]
64
- date = "_".join(parts[2:])
65
- try:
66
- with open(fname, encoding="utf-8") as f:
67
- data = json.load(f)
68
- except Exception:
69
- continue
70
- for fg in data.get("best_flights", []) + data.get("other_flights", []):
71
- legs = fg.get("flights", [])
72
- if not legs:
73
- continue
74
- leg = legs[0]
75
- last_leg = legs[-1]
76
- dep_dt = leg.get("departure_airport", {}).get("time", "")
77
- arr_dt = last_leg.get("arrival_airport", {}).get("time", "")
78
- airline_code = leg.get("airline", "") or ""
79
- airline_iata = (leg.get("airline_logo", "") or "")[-6:-4] if "airline_logo" in leg else ""
80
- flights.append({
81
- "date": date, "from": dep, "to": arr,
82
- "flight": leg.get("flight_number", ""),
83
- "airline": airline_code,
84
- "airline_iata": (leg.get("flight_number", "") or "").split()[0] if leg.get("flight_number") else "",
85
- "departs": dep_dt[-5:] if dep_dt else "",
86
- "arrives": arr_dt[-5:] if arr_dt else "",
87
- "departs_full": dep_dt, "arrives_full": arr_dt,
88
- "price": fg.get("price"),
89
- "duration_min": fg.get("total_duration", 0),
90
- "aircraft": leg.get("airplane", ""),
91
- "delayed": fg.get("often_delayed_by_over_30_min", False),
92
- "stops": max(0, len(legs) - 1),
93
- })
94
- return flights
95
-
96
-
97
- def violations_for(f, max_price, bands, origins, dests, airlines, nonstop_required):
98
- """Return a list of filter names this flight violates, or [] if it passes all."""
99
- v = []
100
- if origins and f["from"] not in origins:
101
- v.append("route")
102
- if dests and f["to"] not in dests:
103
- if "route" not in v:
104
- v.append("route")
105
- if max_price is not None and (f["price"] is None or f["price"] > max_price):
106
- v.append("max-price")
107
- if bands:
108
- if not f["departs"]:
109
- v.append("time-priority")
110
- else:
111
- in_any = any(in_band(f["departs"], lo, hi) for lo, hi in bands)
112
- if not in_any:
113
- v.append("time-priority")
114
- if airlines and f.get("airline_iata") and f["airline_iata"] not in airlines:
115
- v.append("airlines")
116
- if nonstop_required and f.get("stops", 0) > 0:
117
- v.append("nonstop")
118
- return v
119
-
120
-
121
- def priority_band(f, bands):
122
- """Return index of first band the departure time falls into, or len(bands) if outside all."""
123
- if not bands or not f["departs"]:
124
- return 0
125
- for i, (lo, hi) in enumerate(bands):
126
- if in_band(f["departs"], lo, hi):
127
- return i
128
- return len(bands)
129
-
130
-
131
- def cluster_analysis(flights, max_price, bands, origins, dests, airlines, nonstop_required):
132
- """Report which constraint eliminated which flight counts + price distribution."""
133
- prices = sorted({f["price"] for f in flights if f["price"] is not None})
134
-
135
- # Per-constraint impact: how many would be eliminated by this filter alone
136
- constraint_impact = {}
137
- for name in ("route", "max-price", "time-priority", "airlines", "nonstop"):
138
- eliminated = 0
139
- for f in flights:
140
- v = violations_for(f, max_price, bands, origins, dests, airlines, nonstop_required)
141
- if name in v:
142
- eliminated += 1
143
- constraint_impact[name] = {
144
- "eliminated": eliminated,
145
- "of_total": len(flights),
146
- }
147
-
148
- # All-filters combined: how many pass everything
149
- passing_all = sum(
150
- 1 for f in flights
151
- if not violations_for(f, max_price, bands, origins, dests, airlines, nonstop_required)
152
- )
153
-
154
- # Price distribution + natural gaps
155
- gaps = []
156
- if prices:
157
- for i in range(1, len(prices)):
158
- gap = prices[i] - prices[i - 1]
159
- if gap >= 20:
160
- gaps.append({"after": prices[i - 1], "before": prices[i], "gap": gap})
161
-
162
- recommended_budgets = []
163
- if prices:
164
- if gaps:
165
- recommended_budgets.append(gaps[0]["after"])
166
- if len(gaps) > 1:
167
- recommended_budgets.append(gaps[1]["after"])
168
- else:
169
- recommended_budgets.append(prices[len(prices) // 2])
170
-
171
- return {
172
- "total_flights": len(flights),
173
- "passing_all_filters": passing_all,
174
- "constraint_impact": constraint_impact,
175
- "price_distribution": {
176
- "min": prices[0] if prices else None,
177
- "max": prices[-1] if prices else None,
178
- "unique_prices": len(prices),
179
- "natural_gaps": gaps[:5],
180
- "recommended_budgets": recommended_budgets,
181
- },
182
- "applied_filters": {
183
- "max_price": max_price,
184
- "time_priority_bands": [f"{a}-{b}" for a, b in bands],
185
- "origins": sorted(origins) if origins else None,
186
- "destinations": sorted(dests) if dests else None,
187
- "airlines": sorted(airlines) if airlines else None,
188
- "nonstop_required": nonstop_required,
189
- },
190
- }
191
-
192
-
193
- def main() -> int:
194
- p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
195
- default_dir = Path(tempfile.gettempdir()) / "estack-flight-planner"
196
- p.add_argument("--json-dir", default=str(default_dir))
197
- p.add_argument("--max-price", type=int, default=None,
198
- help="Max price in USD. Omit for no price filter.")
199
- p.add_argument("--time-priority", default="",
200
- help="Comma-separated priority bands HH:MM-HH:MM (e.g. 11:00-14:00,14:00-22:00). Empty = no time filter.")
201
- p.add_argument("--from", dest="origins", default="",
202
- help="Comma-separated origin IATA codes. Empty = any origin.")
203
- p.add_argument("--to", dest="dests", default="",
204
- help="Comma-separated destination IATA codes. Empty = any destination.")
205
- p.add_argument("--airlines", default="",
206
- help="Comma-separated airline IATA codes (e.g. UA,DL). Empty = any airline.")
207
- p.add_argument("--nonstop", action="store_true",
208
- help="Require nonstop only (stops==0).")
209
- p.add_argument("--soft-filters", default="",
210
- help="Comma-separated filter names treated as soft (rank, don't exclude). "
211
- "Valid: max-price, time-priority, airlines, nonstop, route.")
212
- p.add_argument("--cluster-analysis", action="store_true",
213
- help="Print constraint-impact + price distribution report instead of filtering.")
214
- args = p.parse_args()
215
-
216
- json_dir = Path(args.json_dir)
217
- if not json_dir.exists():
218
- print(f"ERROR: dir not found: {json_dir}", file=sys.stderr)
219
- return 2
220
-
221
- flights = load_flights(json_dir)
222
- if not flights:
223
- print(f"ERROR: no flights parsed from {json_dir}", file=sys.stderr)
224
- return 2
225
-
226
- origins = parse_set(args.origins)
227
- dests = parse_set(args.dests)
228
- airlines = parse_set(args.airlines)
229
- bands = parse_bands(args.time_priority)
230
- soft = parse_set(args.soft_filters)
231
- bad_soft = soft - SOFT_FILTER_NAMES
232
- if bad_soft:
233
- print(f"ERROR: unknown --soft-filters values: {sorted(bad_soft)}. "
234
- f"Valid: {sorted(SOFT_FILTER_NAMES)}", file=sys.stderr)
235
- return 2
236
-
237
- if args.cluster_analysis:
238
- report = cluster_analysis(flights, args.max_price, bands, origins, dests,
239
- airlines, args.nonstop)
240
- print(json.dumps(report, indent=2))
241
- return 0
242
-
243
- out = []
244
- for f in flights:
245
- v = violations_for(f, args.max_price, bands, origins, dests, airlines, args.nonstop)
246
- hard_v = [name for name in v if name not in soft]
247
- if hard_v:
248
- continue # excluded by a hard filter
249
- f["priority_band"] = priority_band(f, bands)
250
- f["soft_filter_violations"] = v # may be empty
251
- out.append(f)
252
-
253
- # Sort: # of soft violations asc, then priority band, then price, then departs
254
- out.sort(key=lambda f: (
255
- len(f.get("soft_filter_violations", [])),
256
- f.get("priority_band", 0),
257
- f.get("price") if f.get("price") is not None else 10**9,
258
- f.get("departs", ""),
259
- ))
260
- print(json.dumps(out, indent=2))
261
- return 0
262
-
263
-
264
- if __name__ == "__main__":
265
- sys.exit(main())
1
+ """
2
+ Filter and rank flights from saved SerpAPI JSON files.
3
+
4
+ Reads JSON files written by fetch_flights.py, applies filters (price, time
5
+ window, route, airlines, nonstop), ranks results, and outputs a JSON list to
6
+ stdout. Filters can be marked "soft" via --soft-filters — soft filters include
7
+ non-matching flights but flag them with `soft_filter_violations` and rank them
8
+ lower.
9
+
10
+ With --cluster-analysis, prints a constraint-impact report instead of
11
+ filtering: shows which constraint(s) eliminated which flight counts, plus a
12
+ price distribution. The skill uses this to propose specific relaxations when
13
+ hard filters return zero results.
14
+
15
+ Usage:
16
+ python filter_flights.py --json-dir /tmp/estack-flight-planner/ --max-price 200 --from IND --to EWR
17
+ python filter_flights.py --json-dir /tmp/estack-flight-planner/ --max-price 200 \\
18
+ --soft-filters max-price,time-priority
19
+ python filter_flights.py --json-dir /tmp/estack-flight-planner/ --cluster-analysis
20
+
21
+ All filter args (--max-price, --time-priority, --from, --to, --airlines, --nonstop)
22
+ are required for normal filter mode (no defaults — caller passes config values).
23
+ Cluster-analysis mode echoes back impact of each provided filter.
24
+ """
25
+ import argparse
26
+ import glob
27
+ import json
28
+ import sys
29
+ import tempfile
30
+ from pathlib import Path
31
+
32
+
33
+ SOFT_FILTER_NAMES = {"max-price", "time-priority", "airlines", "nonstop", "route"}
34
+
35
+
36
+ def parse_band(b: str):
37
+ a, c = b.split("-")
38
+ return a.strip(), c.strip()
39
+
40
+
41
+ def in_band(t: str, lo: str, hi: str) -> bool:
42
+ return lo <= t <= hi
43
+
44
+
45
+ def parse_set(csv: str):
46
+ if not csv:
47
+ return set()
48
+ return {x.strip() for x in csv.split(",") if x.strip()}
49
+
50
+
51
+ def parse_bands(csv: str):
52
+ if not csv:
53
+ return []
54
+ return [parse_band(b) for b in csv.split(",") if b.strip()]
55
+
56
+
57
+ def load_flights(json_dir: Path):
58
+ flights = []
59
+ for fname in sorted(glob.glob(str(json_dir / "*.json"))):
60
+ parts = Path(fname).stem.split("_")
61
+ if len(parts) < 3:
62
+ continue
63
+ dep, arr = parts[0], parts[1]
64
+ date = "_".join(parts[2:])
65
+ try:
66
+ with open(fname, encoding="utf-8") as f:
67
+ data = json.load(f)
68
+ except Exception:
69
+ continue
70
+ for fg in data.get("best_flights", []) + data.get("other_flights", []):
71
+ legs = fg.get("flights", [])
72
+ if not legs:
73
+ continue
74
+ leg = legs[0]
75
+ last_leg = legs[-1]
76
+ dep_dt = leg.get("departure_airport", {}).get("time", "")
77
+ arr_dt = last_leg.get("arrival_airport", {}).get("time", "")
78
+ airline_code = leg.get("airline", "") or ""
79
+ airline_iata = (leg.get("airline_logo", "") or "")[-6:-4] if "airline_logo" in leg else ""
80
+ flights.append({
81
+ "date": date, "from": dep, "to": arr,
82
+ "flight": leg.get("flight_number", ""),
83
+ "airline": airline_code,
84
+ "airline_iata": (leg.get("flight_number", "") or "").split()[0] if leg.get("flight_number") else "",
85
+ "departs": dep_dt[-5:] if dep_dt else "",
86
+ "arrives": arr_dt[-5:] if arr_dt else "",
87
+ "departs_full": dep_dt, "arrives_full": arr_dt,
88
+ "price": fg.get("price"),
89
+ "duration_min": fg.get("total_duration", 0),
90
+ "aircraft": leg.get("airplane", ""),
91
+ "delayed": fg.get("often_delayed_by_over_30_min", False),
92
+ "stops": max(0, len(legs) - 1),
93
+ })
94
+ return flights
95
+
96
+
97
+ def violations_for(f, max_price, bands, origins, dests, airlines, nonstop_required):
98
+ """Return a list of filter names this flight violates, or [] if it passes all."""
99
+ v = []
100
+ if origins and f["from"] not in origins:
101
+ v.append("route")
102
+ if dests and f["to"] not in dests:
103
+ if "route" not in v:
104
+ v.append("route")
105
+ if max_price is not None and (f["price"] is None or f["price"] > max_price):
106
+ v.append("max-price")
107
+ if bands:
108
+ if not f["departs"]:
109
+ v.append("time-priority")
110
+ else:
111
+ in_any = any(in_band(f["departs"], lo, hi) for lo, hi in bands)
112
+ if not in_any:
113
+ v.append("time-priority")
114
+ if airlines and f.get("airline_iata") and f["airline_iata"] not in airlines:
115
+ v.append("airlines")
116
+ if nonstop_required and f.get("stops", 0) > 0:
117
+ v.append("nonstop")
118
+ return v
119
+
120
+
121
+ def priority_band(f, bands):
122
+ """Return index of first band the departure time falls into, or len(bands) if outside all."""
123
+ if not bands or not f["departs"]:
124
+ return 0
125
+ for i, (lo, hi) in enumerate(bands):
126
+ if in_band(f["departs"], lo, hi):
127
+ return i
128
+ return len(bands)
129
+
130
+
131
+ def cluster_analysis(flights, max_price, bands, origins, dests, airlines, nonstop_required):
132
+ """Report which constraint eliminated which flight counts + price distribution."""
133
+ prices = sorted({f["price"] for f in flights if f["price"] is not None})
134
+
135
+ # Per-constraint impact: how many would be eliminated by this filter alone
136
+ constraint_impact = {}
137
+ for name in ("route", "max-price", "time-priority", "airlines", "nonstop"):
138
+ eliminated = 0
139
+ for f in flights:
140
+ v = violations_for(f, max_price, bands, origins, dests, airlines, nonstop_required)
141
+ if name in v:
142
+ eliminated += 1
143
+ constraint_impact[name] = {
144
+ "eliminated": eliminated,
145
+ "of_total": len(flights),
146
+ }
147
+
148
+ # All-filters combined: how many pass everything
149
+ passing_all = sum(
150
+ 1 for f in flights
151
+ if not violations_for(f, max_price, bands, origins, dests, airlines, nonstop_required)
152
+ )
153
+
154
+ # Price distribution + natural gaps
155
+ gaps = []
156
+ if prices:
157
+ for i in range(1, len(prices)):
158
+ gap = prices[i] - prices[i - 1]
159
+ if gap >= 20:
160
+ gaps.append({"after": prices[i - 1], "before": prices[i], "gap": gap})
161
+
162
+ recommended_budgets = []
163
+ if prices:
164
+ if gaps:
165
+ recommended_budgets.append(gaps[0]["after"])
166
+ if len(gaps) > 1:
167
+ recommended_budgets.append(gaps[1]["after"])
168
+ else:
169
+ recommended_budgets.append(prices[len(prices) // 2])
170
+
171
+ return {
172
+ "total_flights": len(flights),
173
+ "passing_all_filters": passing_all,
174
+ "constraint_impact": constraint_impact,
175
+ "price_distribution": {
176
+ "min": prices[0] if prices else None,
177
+ "max": prices[-1] if prices else None,
178
+ "unique_prices": len(prices),
179
+ "natural_gaps": gaps[:5],
180
+ "recommended_budgets": recommended_budgets,
181
+ },
182
+ "applied_filters": {
183
+ "max_price": max_price,
184
+ "time_priority_bands": [f"{a}-{b}" for a, b in bands],
185
+ "origins": sorted(origins) if origins else None,
186
+ "destinations": sorted(dests) if dests else None,
187
+ "airlines": sorted(airlines) if airlines else None,
188
+ "nonstop_required": nonstop_required,
189
+ },
190
+ }
191
+
192
+
193
+ def main() -> int:
194
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
195
+ default_dir = Path(tempfile.gettempdir()) / "estack-flight-planner"
196
+ p.add_argument("--json-dir", default=str(default_dir))
197
+ p.add_argument("--max-price", type=int, default=None,
198
+ help="Max price in USD. Omit for no price filter.")
199
+ p.add_argument("--time-priority", default="",
200
+ help="Comma-separated priority bands HH:MM-HH:MM (e.g. 11:00-14:00,14:00-22:00). Empty = no time filter.")
201
+ p.add_argument("--from", dest="origins", default="",
202
+ help="Comma-separated origin IATA codes. Empty = any origin.")
203
+ p.add_argument("--to", dest="dests", default="",
204
+ help="Comma-separated destination IATA codes. Empty = any destination.")
205
+ p.add_argument("--airlines", default="",
206
+ help="Comma-separated airline IATA codes (e.g. UA,DL). Empty = any airline.")
207
+ p.add_argument("--nonstop", action="store_true",
208
+ help="Require nonstop only (stops==0).")
209
+ p.add_argument("--soft-filters", default="",
210
+ help="Comma-separated filter names treated as soft (rank, don't exclude). "
211
+ "Valid: max-price, time-priority, airlines, nonstop, route.")
212
+ p.add_argument("--cluster-analysis", action="store_true",
213
+ help="Print constraint-impact + price distribution report instead of filtering.")
214
+ args = p.parse_args()
215
+
216
+ json_dir = Path(args.json_dir)
217
+ if not json_dir.exists():
218
+ print(f"ERROR: dir not found: {json_dir}", file=sys.stderr)
219
+ return 2
220
+
221
+ flights = load_flights(json_dir)
222
+ if not flights:
223
+ print(f"ERROR: no flights parsed from {json_dir}", file=sys.stderr)
224
+ return 2
225
+
226
+ origins = parse_set(args.origins)
227
+ dests = parse_set(args.dests)
228
+ airlines = parse_set(args.airlines)
229
+ bands = parse_bands(args.time_priority)
230
+ soft = parse_set(args.soft_filters)
231
+ bad_soft = soft - SOFT_FILTER_NAMES
232
+ if bad_soft:
233
+ print(f"ERROR: unknown --soft-filters values: {sorted(bad_soft)}. "
234
+ f"Valid: {sorted(SOFT_FILTER_NAMES)}", file=sys.stderr)
235
+ return 2
236
+
237
+ if args.cluster_analysis:
238
+ report = cluster_analysis(flights, args.max_price, bands, origins, dests,
239
+ airlines, args.nonstop)
240
+ print(json.dumps(report, indent=2))
241
+ return 0
242
+
243
+ out = []
244
+ for f in flights:
245
+ v = violations_for(f, args.max_price, bands, origins, dests, airlines, args.nonstop)
246
+ hard_v = [name for name in v if name not in soft]
247
+ if hard_v:
248
+ continue # excluded by a hard filter
249
+ f["priority_band"] = priority_band(f, bands)
250
+ f["soft_filter_violations"] = v # may be empty
251
+ out.append(f)
252
+
253
+ # Sort: # of soft violations asc, then priority band, then price, then departs
254
+ out.sort(key=lambda f: (
255
+ len(f.get("soft_filter_violations", [])),
256
+ f.get("priority_band", 0),
257
+ f.get("price") if f.get("price") is not None else 10**9,
258
+ f.get("departs", ""),
259
+ ))
260
+ print(json.dumps(out, indent=2))
261
+ return 0
262
+
263
+
264
+ if __name__ == "__main__":
265
+ sys.exit(main())