catalyst
Extract and maintain a cross-portfolio catalyst calendar from all thesis notes. Use when user says "catalyst", "calendar", "what's coming up", "upcoming events", or "what could move the portfolio".
SKILL.md.claude/skills/catalyst/SKILL.md
GitHubFollow CLAUDE.md Writing Standards strictly. No hedge words, lead with insights/numbers, tables over prose, every sentence must earn its place.
Build a comprehensive catalyst calendar aggregating every upcoming event across the portfolio.
Progress Reporting Contract (MANDATORY)
/catalyst is a long-running command (~71 thesis + macro reads, FMP fetches, retries) whose intermediate work would otherwise stay silent until the final Phase 6 report — a 5–30+ minute silent window during which errors, retries, FMP timeouts, and degraded-mode downshifts are invisible. To close this gap, emit a one-line status message as plain text between tool calls (not buried inside Bash echo, not as a tool argument) at every phase boundary and every retry / error / degraded-path event.
Why plain text, not Bash echo: Bash echo is visible but rendered inside collapsed tool-output blocks. Plain text between tool calls is the primary signal the user reads to track progress mid-run. Keep the existing echo statements (they're the audit trail) AND add plain-text emits (they're the live progress feed) — both are required.
Format:
- Phase start:
📍 Phase X.Y: [what's starting + expected scale]— e.g.📍 Phase 1: launching parallel read batch (65 theses + 6 macros) - Phase complete:
✅ Phase X.Y: [what completed + key metric]— e.g.✅ Phase 2a: FMP matched 58/64 tickers, 6 queued for WebSearch - Soft failure / degraded path:
⚠️ Phase X.Y: [what failed + what's happening next]— e.g.⚠️ Phase 2a: FMP first attempt failed (rc=99 empty array), retrying in 5s - Hard failure / abort:
❌ Phase X.Y: [what failed + abort reason]— e.g.❌ Phase 0.2: snapshot cp failed, aborting before any destructive write
Cadence: every phase boundary (Phase 0.1, 0.2, 0.3, 1, 2a, 2b, 3, 4, 5, 6) + every retry / error / cap-activation event. Aim for one status line every 10–30 seconds of wall-clock work; silences longer than that mean the user wonders if the skill is stuck.
Do not emit per-file progress within a parallel batch (e.g., "read thesis 1 of 65", "WebSearch 3 of 20"). Batch-level start/complete is enough; per-file noise drowns the signal.
Reference scale (2026-05-23 portfolio): 65 theses, 6 macro notes, ~64 active/monitoring tickers for FMP enrichment. Phases 1 and 2 are the two longest segments — emit start AND complete messages for both even if they feel redundant.
Phase 0: Pre-flight (MANDATORY — runs before Phase 1)
0.1: Acquire vault lock
Acquire a vault-wide scope lock per .claude/skills/_shared/preflight.md Procedure 1. Timeout budget: 5 minutes. Capture the token, verify ownership (Procedure 1.5) at every subsequent Bash block, release in the final reporting Bash block.
📍 Emit before: 📍 Phase 0.1: acquiring vault-wide lock
✅ Emit after: ✅ Phase 0.1: lock acquired (token <token>)
⚠️ Emit on stale-lock break-in or contention: ⚠️ Phase 0.1: existing lock found — [stale (broken) | active (waiting Xs)]
0.2: Snapshot existing _catalyst.md (mandatory pre-regenerate snapshot — H2 fix: hard-abort on failure)
Before regenerating _catalyst.md, snapshot the prior version. If the snapshot cp fails, HARD ABORT /catalyst — do not proceed to regenerate, because a regenerate failure after a failed pre-snapshot loses the prior calendar with no recovery path.
📍 Emit before: 📍 Phase 0.2: snapshotting prior _catalyst.md
✅ Emit after: ✅ Phase 0.2: snapshot written to _Archive/Snapshots/_catalyst (pre-catalyst <date>-<HHMMSS>).md
ℹ️ Emit when no prior file: ℹ️ Phase 0.2: no prior _catalyst.md — first run, snapshot skipped
❌ Emit on hard abort: ❌ Phase 0.2: snapshot failed (<cp error | empty file>) — aborting before any destructive write
HHMMSS=$(date +%H%M%S)
mkdir -p _Archive/Snapshots
SNAPSHOT_PATH="_Archive/Snapshots/_catalyst (pre-catalyst YYYY-MM-DD-$HHMMSS).md"
if [ -f "_catalyst.md" ]; then
if ! cp "_catalyst.md" "$SNAPSHOT_PATH"; then
echo "SNAPSHOT_FAILED|$SNAPSHOT_PATH"
exit 1
fi
# Verify the copy actually landed with non-zero size (cp can silently no-op on some filesystems)
if [ ! -s "$SNAPSHOT_PATH" ]; then
echo "SNAPSHOT_EMPTY|$SNAPSHOT_PATH"
rm -f "$SNAPSHOT_PATH"
exit 1
fi
fi
Then add snapshot frontmatter to the newly created file:
snapshot_of: "[[_catalyst.md]]"
snapshot_date: YYYY-MM-DD
snapshot_trigger: catalyst
snapshot_batch: catalyst-YYYY-MM-DD-HHMMSS
Snapshot failure handling (H2): if either the cp fails or the resulting file is empty, abort with:
❌ /catalyst pre-regenerate snapshot failed: [path]
Reason: [cp error | empty file after copy]
No regeneration attempted. The current _catalyst.md is unchanged. Fix the
underlying filesystem issue (disk space, permissions, _Archive/Snapshots/
directory writability) and re-run /catalyst. Prior calendar is intact.
Exit the skill cleanly — do NOT proceed to Phase 1. The prior _catalyst.md is preserved at its current path; the next successful /catalyst run will snapshot and regenerate.
Frontmatter-add failure: if the cp succeeds but the subsequent frontmatter Edit fails, proceed with regeneration anyway — the snapshot file content is preserved (bit-exact copy of the prior _catalyst.md), just without the snapshot_of: metadata. /rollback can still restore it via direct cp-back; it won't appear in /rollback cascade detection by batch ID, but the user can locate it by filename pattern (_catalyst (pre-catalyst *)).
Why the hard abort matters: prior behavior proceeded with regeneration after silent cp failure. If web search then failed mid-regenerate, the partial calendar overwrote the prior version with no snapshot to roll back to. Daily backup was the only fallback. Hard-abort guarantees the prior calendar is always recoverable from the pre-regenerate snapshot before any destructive write.
If no prior _catalyst.md exists (first run), skip the snapshot step (the [ -f "_catalyst.md" ] guard handles this) and proceed to Phase 1.
0.3: FMP API key probe (soft — degrade-on-failure)
Try to load the FMP API key. Unlike /numbers and /transcript, /catalyst does NOT abort on FMP failure — it falls back to WebSearch (the prior behavior). Capture the result as a flag for Phase 2.
📍 Emit before: 📍 Phase 0.3: probing FMP API key
✅ Emit on success: ✅ Phase 0.3: FMP key loaded — Phase 2a will use FMP universe endpoint
⚠️ Emit on miss: ⚠️ Phase 0.3: no FMP key — Phase 2 will use WebSearch fallback only (capped at 20)
FMP_OK=0
if [ -f .data/config.json ]; then
API_KEY=$(jq -r '.fmp_api_key // empty' .data/config.json) # jq, not sed -E '\s' (BSD sed unsupported → whole-line key → rc=3 curls)
if [ -n "$API_KEY" ] && [ "$API_KEY" != "null" ]; then
FMP_OK=1
echo "FMP_OK"
fi
fi
[ "$FMP_OK" = "0" ] && echo "FMP_UNAVAILABLE — Phase 2 will use WebSearch fallback only"
Record FMP_OK and API_KEY for Phase 2. The skill's primary deliverable (the calendar) is functional in either mode — FMP is a speed/precision upgrade, not a correctness gate.
Quartr probe (soft, same discipline): per _shared/quartr.md §1–§2, ToolSearch-load mcp__quartr__search_companies,mcp__quartr__list_events; set QUARTR_OK=1 on success, 0 otherwise (emit ⚠️ no Quartr — Phase 2a-bis skipped, WebSearch fallback takes the full miss queue). Quartr feeds Phase 2a-bis (structured earnings dates for FMP-miss tickers — precisely the non-US listings FMP's universe calendar drops — plus non-earnings catalyst events FMP cannot see at all).
Phase 1: Load the Portfolio (Parallel Full Reads)
📍 Emit before: 📍 Phase 1: launching parallel read batch (<N> theses + <M> macros = <N+M> Reads in one round-trip)
✅ Emit after: ✅ Phase 1: <N+M> files loaded — moving to extraction
⚠️ Emit if any Read fails: ⚠️ Phase 1: <K>/<N+M> reads failed (<filenames>) — continuing with available files
Issue all thesis Reads AND all macro Reads in a single parallel tool-call batch — one message with ~71 Read invocations (65 theses + 6 macros at current scale; scales with vault), not serial loops. This collapses the I/O into one round-trip while preserving full-file context: the LLM sees every thesis body in full so cross-section catalyst signal (e.g., a regulatory date mentioned in Bull Case, a product launch referenced in Business Model) isn't lost to over-aggressive section pre-filtering.
1.1 Parallel batch composition
- Read every
Theses/*.mdin full (all files — draft, active, monitoring; every thesis can carry catalysts). - Read every
Macro & Technology/*.mdin full (bounded set, ~6 files — rate decisions, geopolitical deadlines, commodity contract expirations, AI bubble risk, stablecoin regulation, etc.).
All reads land in one round-trip. Do NOT serialize per-file.
1.2 Extract from the pulled content
Within each thesis, prioritize Catalysts, Risks, and the last 5 Log entries (highest-density sections for event extraction) but maintain peripheral awareness of other sections — a catalyst may surface in Bull/Bear Case, Summary, or Business Model when the author didn't duplicate it into the Catalysts list. Full-file context is kept intentionally; /catalyst favors completeness over narrow extraction.
Per thesis:
- Catalysts section: event description, approximate date or date range (if stated), expected impact direction (positive / negative / uncertain), magnitude estimate (major / minor).
- Conviction Triggers section (when present — ~half the theses): extract each
→ HIGH if/→ LOW if/→ CLOSE ifstatement and pair it with any catalyst whose outcome would test it. This feeds theThesis testcolumn (Phase 4) — the calendar's falsification layer. The 000660 thesis's Catalysts table ("Positive if SK Hynix maintains ≥60% share") is the in-vault exemplar of the target pairing. - Log entries: recently noted upcoming events (scan the last ~5 date headers; older entries typically describe past events).
- Risks section: risk events with timing (e.g., "regulatory decision expected Q2").
- Elsewhere in the thesis: flag any date-anchored event or upcoming catalyst mentioned in Bull Case, Bear Case, Summary, Industry Context, or Business Model that's absent from the Catalysts section — surface these as candidates the thesis may need to formalize.
- Macro notes: cross-portfolio macro events (rate decisions, geopolitical deadlines, commodity contract expirations).
Phase 2: Enrich and Deduplicate
📍 Emit at Phase 2 start: 📍 Phase 2: enriching catalysts with FMP earnings dates (FMP_OK=<0|1>)
Where catalyst dates are vague ("Q2 2026", "H2"), convert to approximate calendar dates
Identify catalysts that affect MULTIPLE theses (e.g., an earnings report from a major customer that affects several supplier theses)
Flag catalysts that are stale — events that appear to have already passed based on their dates vs today's date
Enrich with upcoming earnings dates — FMP-first, WebSearch fallback.
2a. FMP universe call (when
FMP_OK=1from Phase 0.3)📍 Emit before:
📍 Phase 2a: calling FMP universe earnings calendar (TODAY → +90d)✅ Emit after success:✅ Phase 2a: FMP returned <rows> earnings rows, matched <hits>/<active+monitoring> theses⚠️ Emit on retry:⚠️ Phase 2a: FMP attempt 1 failed (rc=<rc>) — retrying in 5sthen✅ Phase 2a: FMP retry succeededOR⚠️ Phase 2a: FMP retry also failed — downshifting to WebSearch fallback (capped at 20)Single curl to the universe earnings calendar over the next 90 days. One round-trip replaces what would otherwise be ~64 WebSearches (one per active/monitoring thesis). Retry-once on transient failure before downshifting to the WebSearch fallback storm — empirically (2026-05-23 incident: two consecutive
/catalystruns at 14:50 and 15:12 timed out at ~9 min each because Phase 2a hit transient FMP failures and downshifted to a full 64-ticker WebSearch storm; a 5-second retry would have salvaged both runs since FMP was healthy by 15:21).TODAY=$(date +%Y-%m-%d) END=$(date -v+90d +%Y-%m-%d 2>/dev/null || date -d '+90 days' +%Y-%m-%d) FMP_URL="https://financialmodelingprep.com/stable/earnings-calendar?from=$TODAY&to=$END&apikey=$API_KEY" fetch_fmp() { curl -sf --max-time 20 --connect-timeout 5 "$FMP_URL" > /tmp/catalyst_earnings.json local rc=$? if [ $rc -ne 0 ]; then return $rc; fi # Empty array is a known intermittent failure mode — treat as soft failure for retry purposes local rows rows=$(jq 'length' /tmp/catalyst_earnings.json 2>/dev/null) if [ "$rows" = "0" ] || [ -z "$rows" ]; then return 99; fi return 0 } FMP_FETCH_RC=0 if ! fetch_fmp; then FMP_FETCH_RC=$? echo "⚠️ FMP first attempt failed (rc=$FMP_FETCH_RC). Retrying after 5s..." sleep 5 if ! fetch_fmp; then FMP_FETCH_RC=$? echo "⚠️ FMP retry failed (rc=$FMP_FETCH_RC). Downshifting to capped WebSearch fallback." FMP_OK=0 else echo "✅ FMP retry succeeded." fi fiTimeout rationale: FMP's universe earnings-calendar endpoint can TCP-establish and then stall mid-stream from networks with degraded routes (observed 2026-05-23: 500–600ms RTT, intermittent recv failures,
HTTP:000 SIZE:0responses). Without--max-time, curl defaults to no transfer timeout — a stalled connection blocks/catalystuntil the kernel TCP keepalive eventually closes the socket (5–10 min), which surfaces to the user as a skill stall with no progress signal.--max-time 20caps total transfer at 20s;--connect-timeout 5caps TCP handshake at 5s. Both well above healthy-path latency (~2s end-to-end) but tight enough to deterministically fall through to the retry / WebSearch path below.Retry rationale: prior design said "Do NOT retry — the universe endpoint failing usually means the API key is wrong or quota is exhausted, neither of which a retry fixes." Reality: the dominant failure mode observed in production is transient (intermittent network blips, brief upstream slowness, momentary empty-array responses) — exactly the failure class a single 5-second retry fixes. Persistent failures (wrong key, exhausted quota) fail both attempts and still downshift correctly. Worst-case Phase 2a cost rises from 20s → 45s (initial 20s timeout + 5s sleep + 20s retry timeout), which is a rounding error against the 5-minute lock budget — and orders of magnitude cheaper than the 9-minute WebSearch storm the retry prevents.
Parse the JSON (array of
{symbol, date, epsEstimated, revenueEstimated, time}). Build asymbol → next_earningmap by sorting each symbol's entries ascending bydateand taking the first row.For each thesis with
status: active | monitoring, look up its ticker in the map:- Hit: record the date + EPS/revenue estimates + reporting time (BMO/AMC). Source label:
FMP. - Miss: queue the ticker for the WebSearch fallback (typically non-US listings —
000660.KS,6981.T,ASM.AS,BESI.AS,AIXA.DE,GAW.L,IQE.L,SIVE.ST,YAL.AX— which FMP's universe calendar doesn't always cover; per-ticker coverage works via the transcript endpoint but the calendar endpoint is US-biased).
FMP call failure handling (after retry):
- Both attempts fail (HTTP error, curl error, or empty array) →
FMP_OK=0set by the retry block above. Phase 2b enters capped-fallback mode. Do NOT retry a third time — by this point the failure is persistent (API key wrong, quota exhausted, or sustained upstream outage), and further retries cost time without changing outcome.
2a-bis. Quartr per-ticker events for the miss queue (when
QUARTR_OK=1)📍 Emit before:
📍 Phase 2a-bis: Quartr event lookup for <queue_size> FMP-miss tickers✅ Emit after:✅ Phase 2a-bis: <hits> earnings dates resolved via Quartr, <extras> non-earnings events captured, <remainder> tickers → WebSearch queueRuns on the Phase 2a miss queue (or the full active+monitoring list when
FMP_OK=0) BEFORE any WebSearch. Per queued ticker, per_shared/quartr.md§3: resolve companyId (quartr_id:frontmatter →.data/quartr_ids.jsoncache →search_companiesby company name with verification; cache verified hits), then:mcp__quartr__list_events {companyId: ID, expand: [], startDate: TODAY, order: "asc", limit: 10}- Next earnings: first event with
parentEventType: "earnings_call"→ record date (+ exact datetime — Quartr carries call time, so BMO/AMC falls out of the timestamp) with source labelQuartr. Remove the ticker from the WebSearch queue. - Non-earnings catalyst events (bonus rows no other source provides): any
capital_markets_day/investor_day/analyst_day/r_and_d_day/annual_general_meeting/guidanceevent inside the 90-day window → add as its own calendar row, type tagged (CMD,AGM,guidance), sourceQuartr. Plainconferenceappearances are NOT calendar rows (too noisy — a fireside is not a catalyst) unless the thesis's Catalysts section already names that conference. - No confident company match / no events: ticker stays in the WebSearch queue (
QUARTR_MISS— never guess).
Cost profile: resolution is one
search_companiesper ticker ONCE ever (the ID cache persists); steady-state is onelist_eventsper queued ticker (~5-9 international names on a healthy FMP run). No retry loops — a failed call sends the ticker to the WebSearch queue. This phase typically empties the queue that previously consumed the WebSearch cap: coverage confirmed 2026-09-09 for the hard cases (KOSDAQ 036930, TWSE 2383/8069,.NS,.ST,.Llistings).2a-ter. Conference clustering (when
QUARTR_OK=1and.data/quartr_watchlist.jsonexists)📍 Emit before:
📍 Phase 2a-ter: clustering upcoming conference appearances (watchlist <id>)✅ Emit after:✅ Phase 2a-ter: <N> conference events grouped into <M> conferences, <K> multi-name clustersA conference where several portfolio names present in the same week is a catalyst in its own right (management access, cross-company signal, sector-wide narrative moves) — and no other source sees it. One call against the
/event-sweep-maintained watchlist (read-only per that skill's constraint #3; state file absent → skip withℹ️ no Quartr watchlist — run /event-sweep --setup, no per-ticker fan-out substitute):mcp__quartr__list_events {watchlistId, startDate: TODAY, endDate: TODAY+90d, eventTypes: ["conference", "fireside_chat", "capital_markets_day", "investor_day", "analyst_day", "r_and_d_day"], expand: [], limit: 200, order: "asc"}Group returned events by conference
title. For clusters of ≥2 portfolio names, optionallyget_conference(via the event's conference linkage where available; cap 5 calls) for full presenter counts/dates. Feed Phase 4 two ways: (1) each CMD/investor-day/analyst-day event becomes its own calendar row (type-tagged, sourceQuartr— these are per-company catalysts regardless of clustering); (2) multi-name conference clusters render in the new**Conference clusters (next 90d)**block of_catalyst.md(see Phase 4 Structure) with conference name, dates, and the portfolio names presenting. Plain single-name conference appearances still do NOT become calendar rows (the 2a-bis noise rule stands) — they surface only inside a multi-name cluster where the co-presence is the signal.2b. WebSearch fallback (CAPPED — hard limit 20 per run)
📍 Emit before:
📍 Phase 2b: WebSearch fallback for <queue_size> tickers (cap=20, mode=<healthy-int'l | overflow | full-fallback>)⚠️ Emit when cap activates:⚠️ Phase 2b: queue (<queue_size>) exceeds cap — running top 20 by conviction+recency, surfacing <queue_size-20> dropped tickers in Notes block✅ Emit after:✅ Phase 2b: <N> WebSearches complete, <hits> dates extractedFor the ticker set still uncovered after Phases 2a + 2a-bis (with
QUARTR_OK=0andFMP_OK=0this is the full active+monitoring list) — send WebSearch invocations in parallel: up to 25 invocations per message, then the next batch, never serialized.Query pattern:
"[Company name] next earnings date 2026".Hard cap: 20 WebSearches per run, prioritized by conviction. Empirically (2026-05-23 incident) an uncapped 64-ticker fallback storm took >9 minutes and exceeded the host kill budget, leaving Phase 4 unreached and a stale
.vault-lockorphaned at vault root. The cap bounds worst-case Phase 2b runtime at ~3 minutes (20 calls × ~10s each, internally rate-limited by the WebSearch backend) so total/catalystruntime stays under the 5-minute lock budget even in full-fallback mode.Prioritization (when queue size > 20):
- Sort by
conviction(high → medium → low → unset) - Within conviction tier, sort by frontmatter
datedescending (most recent thesis first — these are most likely to drive near-term decisions) - Take the top 20
- Surface the dropped tickers in Phase 4's
Notesblock under the calendar:⚠️ Earnings dates for [N] tickers not refreshed this run (FMP fallback cap): [ticker list]. Re-run /catalyst when FMP is healthy.
Run modes by queue size:
- Healthy FMP path (
FMP_OK=1, queue ≤ 20): run all WebSearches (typically 5–9 international tickers). No cap activation; no warning. - Healthy FMP path with overflow (
FMP_OK=1, queue > 20): rare — would only happen if FMP universe call returns partial data missing many US listings. Cap activates, surface warning. - Full fallback (
FMP_OK=0, queue = all 64 active/monitoring): cap activates. Run top 20 by conviction + recency. Calendar carries⚠️ FMP unavailable — calendar reflects 20/64 active+monitoring tickers; remainder pulled from existing thesis Catalysts sections only. Re-run when FMP is healthy.at the top of the file.
2c. Record source per ticker
In the in-memory earnings map, tag every entry with its source (
FMP,Quartr, orWebSearch). Phase 4's_catalyst.mdNotescolumn surfaces this —FMPentries get aconfirmed via FMPtag,Quartrentriesconfirmed via Quartr(equal confidence — structured event data, and the only source carrying exact call datetimes for non-US listings),WebSearchentries getest. via news search. Lets the user weight the calendar's confidence at a glance.2d. Clean up
rm -f /tmp/catalyst_earnings.json- Hit: record the date + EPS/revenue estimates + reporting time (BMO/AMC). Source label:
Phase 3: Analyse Catalyst Clusters
📍 Emit before: 📍 Phase 3: clustering catalysts (concentration, gaps, asymmetric, cross-thesis)
✅ Emit after: ✅ Phase 3: <N> clusters identified, <M> no-catalyst theses, <K> cross-thesis events (3+ tickers)
- Concentration risk: Are there weeks where multiple catalysts cluster? This creates portfolio-level volatility
- Catalyst gaps: Which theses have NO near-term catalyst? Flag as thesis drift risk — a position with no catalyst is dead capital
- Asymmetric events: Which catalysts have the widest range of possible outcomes? These are the highest-attention-priority events
- Cross-thesis catalysts: Events that simultaneously affect 3+ positions (e.g., a macro data release, a major customer's earnings)
Phase 4: Output
📍 Emit before: 📍 Phase 4: writing _catalyst.md (6 tables + Notes block)
✅ Emit after: ✅ Phase 4: _catalyst.md written (<word_count> words, <total_events> events across <unique_tickers> tickers)
Snapshot already taken in Phase 0.2 with the mandatory hard-abort guard (§1.3 invariant). Do NOT re-snapshot here — a second
cpwithout the hard-abort/non-empty check would silently overwrite Phase 0.2's protective snapshot under filesystem pressure, destroying the recovery path. The Phase 0.2 snapshot covers the pre-regenerate state for the entire run.
Save/update the catalyst calendar at _catalyst.md (overwrite each run — this is a regenerated utility file like _hot.md, not a research note):
---
date: YYYY-MM-DD
tags: [meta, catalyst-calendar]
---
Structure:
Next 2 Weeks (day-by-day detail)
| Date | Ticker(s) | Event | Expected Impact | Magnitude | Thesis test | Notes |
|---|
Weeks 3-4
| Week of | Ticker(s) | Event | Expected Impact | Thesis test | Notes |
|---|
Conference clusters (next 90d) (present only when Phase 2a-ter ran and found ≥1 multi-name cluster)
| Conference | Dates | Portfolio names presenting | Notes |
|---|
Cluster rows carry the Quartr conference/event links; a cluster within the next 2 weeks also earns a one-line mention in the relevant day rows of the Next 2 Weeks table (part of [Conference] cluster — see Conference clusters). Post-event, /event-sweep's weekly digest picks up the transcripts — the calendar's job ends at the appointment.
Thesis test column (falsification layer — READING PROTOCOL). Format: [observable + threshold] → [confirms Insight #n | fires "→ LOW/HIGH/CLOSE if …" trigger of [[TICKER]] | untested-unmatched | untested-no-trigger]. Populate from the Phase 1.2 Conviction-Triggers extraction — the thesis is already fully loaded, zero extra reads. This converts the calendar from a when-and-which-way schedule into a pre-registered hypothesis-test schedule: for each event, what outcome would CONFIRM or REFUTE which thesis.
Split the null case — the two "untested" values mean different things and imply different fixes:
untested-unmatched— the thesis HAS## Conviction Triggersbut none matches this event. Signal: the trigger set doesn't cover this event's risk → suggest a/deepen TICKER "Conviction Triggers"to add one. Actionable gap.untested-no-trigger— the thesis has NO## Conviction Triggerssection at all. Signal: nothing defined can falsify the thesis → the event is unmoored from any pre-registered test. This is the stronger/prune+/lint #60signal; suggest creating triggers first.
A portfolio heavy in untested-no-trigger means the theses lack falsification machinery entirely; a portfolio heavy in untested-unmatched means the machinery exists but has coverage holes. Do not collapse them into one bucket — the distinction is the whole point.
Earnings-within-14-days → /transcript prompt. For any earnings event in the Next 2 Weeks table (Phase 2 enrichment supplied the date), tag its Notes cell → run /transcript TICKER once reported. /transcript extracts management-language deltas + Q&A tone + trigger touches — the vault's highest-signal, most-underused earnings input (it has run once, ever). The catalyst calendar is the natural place to surface that a transcript will be pullable within days, so the earnings event actually gets ingested instead of passing unremarked. Collect these into the Phase 6.2 report as a Transcript-ready this fortnight: line.
Month 2-3 (grouped by week)
| Approximate Date | Ticker(s) | Event | Notes |
|---|
No Catalyst Identified
| Ticker | Last Catalyst Date | Days Since | Falsifiable? | Status |
|---|---|---|---|---|
[Theses with no upcoming catalyst — flag for attention. Falsifiable? = does the thesis have a ## Conviction Triggers section (yes/no)? A thesis with NO catalyst AND NO triggers is doubly dead capital — nothing scheduled can move it and nothing defined can kill it: the strongest /prune candidate signal this calendar produces.] |
Stale Catalysts (events that appear to have passed)
| Ticker | Event | Listed Date | Action Needed |
|---|---|---|---|
| [Catalysts whose dates have passed — thesis needs updating] |
Cross-Thesis Events [Events affecting 3+ theses, with impact assessment for each]
Phase 5: Update _hot.md
📍 Emit before: 📍 Phase 5: updating _hot.md (Active Research Thread + Open Questions for <K> no-catalyst tickers)
✅ Emit after: ✅ Phase 5: _hot.md updated (<word_count> words, <new_open_qs> new Open Questions added)
⚠️ Emit if word cap pruning triggers: ⚠️ Phase 5: _hot.md over soft cap (8,000w) — pruning <section> oldest entries until under cap
❌ Emit if hard cap hit: ❌ Phase 5: _hot.md over hard cap (10,000w) after pruning — aborting _hot.md update per contract
Read _hot.md then edit (do NOT touch Latest Sync or Sync Archive — those are /sync-owned). If _hot.md does not exist, create it per the CLAUDE.md Rule #9 schema.
Active Research Thread: Same-topic continuation — if the current thread already covers catalyst-calendar work, append a dated line (
YYYY-MM-DD: refreshed catalyst calendar — [key takeaway, e.g. "2 dense weeks ahead, 3 theses with no catalyst"]). New topic: compress the outgoing thread into a single*Previous:*entry (date + one-phrase summary). Write the new thread focused on catalyst state:YYYY-MM-DD: refreshed catalyst calendar — next 2 weeks = [N] catalysts across [M] theses; [K] no-catalyst theses flagged for review.Append*Previous:*line(s) — max 5, drop oldest.Open Questions: For every ticker flagged under "No Catalyst Identified" in the catalyst calendar output, add a question (if not already present):
- [[TICKER]]: what catalyst in next 90 days could reignite this thesis? (flagged by /catalyst YYYY-MM-DD). Dedupe by question pattern, not by ticker presence — only suppress this entry if an existing Open Question already contains BOTH the ticker wikilink AND the substringcatalyst in next 90 days could reignite this thesis. Other pre-existing questions about the ticker (e.g., from/thesis,/stress-test,/surface) must not suppress the catalyst-specific question; a thesis can legitimately have multiple open questions at once, and over-broad dedup would silently drop the catalyst gap from the user's attention surface.Recent Conviction Changes: not touched by
/catalyst.Portfolio Snapshot: not touched by
/catalyst.
Word cap: After edits, check total word count. If over 4,000 words (soft cap per _shared/hot-md-contract.md), prune ## Sync Archive entries (oldest first), then *Previous:* lines in Active Research Thread (oldest first), until under cap. If over 5,000 (hard cap), abort _hot.md update per contract.
Phase 6: Report + lock release
📍 Emit at Phase 6 start: 📍 Phase 6: releasing lock and composing final report
✅ Emit after lock release: ✅ Phase 6: lock released cleanly (token <token>) — or ⚠️ Phase 6: lock token mismatch — leaving lock in place if §6.1 found a mismatch.
Runs in the main conversation (no fork) — the Phase 6 report renders directly in chat. The 71 full thesis + macro reads (400K tokens at current vault scale) consume main-session context, so run /catalyst in a fresh conversation when context budget matters; _catalyst.md persists to disk as the authoritative calendar for later reading.
6.1 Lock release (MUST run before the report, even if Phases 1–5 had non-fatal errors)
Issue the lock-release Bash block FIRST in Phase 6 — before composing the report. The trap-on-INT/TERM cleanup from Phase 0.1 only fires on signal-based termination; if the run is killed via SIGKILL (host timeout enforcement) or exits cleanly, the trap never runs. Explicit release here is the primary cleanup path; trap is the fallback.
# Verify token ownership before deletion (Procedure 1.5 — never delete a lock you don't own)
LOCK_FILE=".vault-lock"
EXPECTED_TOKEN="<token captured from Phase 0.1>"
if [ -f "$LOCK_FILE" ]; then
ACTUAL_TOKEN=$(grep '^token:' "$LOCK_FILE" | sed 's/token: //')
if [ "$ACTUAL_TOKEN" = "$EXPECTED_TOKEN" ]; then
rm -f "$LOCK_FILE"
echo "✅ Released vault-wide lock (token $EXPECTED_TOKEN)"
else
echo "⚠️ Lock token mismatch (expected $EXPECTED_TOKEN, found $ACTUAL_TOKEN). NOT releasing — another skill owns this lock now (likely because timeout_at passed and another skill broke in). Leaving in place."
fi
else
echo "ℹ️ Lock file already removed (trap may have fired, or skill ran past timeout_at and was reaped)."
fi
Why this matters: empirically (2026-05-23 incident) when the /catalyst subagent was killed at 9 min by host timeout, the trap-on-INT/TERM cleanup did not fire and .vault-lock was left orphaned at vault root for ~10 minutes until manually removed. While the orphaned lock's timeout_at had already passed (so other skills would treat it as stale via §1.3a's expiry check), it remained as clutter and a source of operator confusion. Explicit release closes the gap between "skill ran successfully" and "lock is gone".
6.2 Report
Report to user: next 2 weeks of catalysts, any dangerous clusters, and the list of theses with no catalyst (these need attention or pruning). Include one line summarizing the _hot.md update: _hot.md: Active Research Thread refreshed; [K] no-catalyst tickers added to Open Questions. Point the user to [[_catalyst.md]] for the full calendar tables (Next 2 Weeks, Weeks 3-4, Month 2-3, No Catalyst Identified, Stale Catalysts, Cross-Thesis Events).
Add a Transcript-ready this fortnight: [TICKER (date), …] — run /transcript after each reports line whenever any earnings event lands in the next 14 days (from the Phase 4 tagging). Omit the line entirely when none do — no noise on quiet fortnights.
If Phase 2b's WebSearch cap activated, surface the degraded-mode warning in the report header: ⚠️ FMP unavailable this run — calendar reflects top-20-by-conviction WebSearch results; [N] tickers carry pre-existing thesis Catalysts data only. Re-run /catalyst when FMP is healthy for full refresh.