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".
3 stars
0 votes
0 copies
0 views
Added September 11, 2026
businessgoshellbashgitapibackend
Works with
api
mcp
Security analysis
B88/100
criticalSends environment variables or credentials to an external URL
Installs into .claude/skills of the current project.
Are you the author of Catalyst?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/jameswong2011-catalyst)
---
name: catalyst
description: 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".
---
**Codex execution:** Read `.agents/skills/_shared/codex-compat.md` first. Treat `SKILL_ARGS` as the arguments supplied with `$catalyst`, or infer them from the user's request when this skill is invoked implicitly.
**Follow AGENTS.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 shell `echo`, not as a tool argument) at every phase boundary and every retry / error / degraded-path event.
**Why plain text, not shell echo**: shell `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 web search`
- 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", "web search 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 `.agents/skills/_shared/preflight.md` Procedure 1. Timeout budget: 5 minutes. Capture the token, verify ownership (Procedure 1.5) at every subsequent shell block, release in the final reporting shell 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`
```bash
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:
```yaml
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 web search (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 web search fallback only (capped at 20)`
```bash
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 web search 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.
## 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/*.md` in full (all files — draft, active, monitoring; every thesis can carry catalysts).
- **Read** every `Macro & Technology/*.md` in 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 if` statement and pair it with any catalyst whose outcome would test it. This feeds the `Thesis test` column (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 Quartr events (primary) then FMP estimates (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 events — Quartr `list_events` on Vault Active (30182) + Vault Monitoring (if seeded) is primary.** Covers earnings, CMDs, firesides, conferences, AGMs, trading updates, private SpaceX. FMP is kept only for consensus EPS/revenue *estimates* (Quartr has none). web search is the last-resort cap-20 path when both miss.
### 2a0. Quartr watchlist events (primary — 2026-09-09)
📍 **Emit before**: `📍 Phase 2a0: Quartr list_events on watchlist 30182 (+ monitoring) TODAY → +90d`
✅ **Emit after**: `✅ Phase 2a0: Quartr returned <N> events across <M> tickers`
⚠️ **Emit on MCP miss**: `⚠️ Phase 2a0: Quartr MCP unavailable — downshifting to FMP universe calendar`
Per `.agents/skills/_shared/quartr.md`. `list_events` with `watchlistId: 30182` (and monitoring_id from `.data/quartr_watchlists.json` if set), `startDate=TODAY`, `endDate=+90d`, `order: asc`, `expand: ["documents", "expectedContent", "transcript"]`. Also `list_conferences` for the same window; conferences that list a book ticker become calendar rows (this is how GS Communacopia / Citi TMT show up).
Map each event to a thesis via `quartr_id:` / watchlist JSON. Record date, event type, eventId, whether a transcript is expected/available. Source label: `Quartr`.
Transcript-ready tag (Phase 4 Notes): `→ $transcript TICKER --event ID` once the event has a transcript (or on the reporting date for earnings). Never the old `→ $transcript TICKER once reported` form.
Tickers with no Quartr event in +90d queue for FMP estimates (2a) then web search (2b).
### 2a. FMP universe call (when `FMP_OK=1` from Phase 0.3) — estimates + residual misses
📍 **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 5s` then `✅ Phase 2a: FMP retry succeeded` OR `⚠️ Phase 2a: FMP retry also failed — downshifting to web search 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 web searches (one per active/monitoring thesis). **Retry-once on transient failure** before downshifting to the web search fallback storm — empirically (2026-05-23 incident: two consecutive `$catalyst` runs 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 web search storm; a 5-second retry would have salvaged both runs since FMP was healthy by 15:21).
```bash
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 web search fallback."
FMP_OK=0
else
echo "✅ FMP retry succeeded."
fi
fi
```
**Timeout 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:0` responses). Without `--max-time`, curl defaults to no transfer timeout — a stalled connection blocks `$catalyst` until 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 20` caps total transfer at 20s; `--connect-timeout 5` caps TCP handshake at 5s. Both well above healthy-path latency (~2s end-to-end) but tight enough to deterministically fall through to the retry / web search 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 web search storm the retry prevents.
Parse the JSON (array of `{symbol, date, epsEstimated, revenueEstimated, time}`). Build a `symbol → next_earning` map by sorting each symbol's entries ascending by `date` and 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 web search 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=0` set 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.
### 2b. web search fallback (CAPPED — hard limit 20 per run)
📍 **Emit before**: `📍 Phase 2b: web search 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> web searches complete, <hits> dates extracted`
For the queued (uncovered) ticker set — or for the full thesis list if `FMP_OK=0` — send web search 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 web searches 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-lock` orphaned at vault root. The cap bounds worst-case Phase 2b runtime at ~3 minutes (20 calls × ~10s each, internally rate-limited by the web search backend) so total `$catalyst` runtime stays under the 5-minute lock budget even in full-fallback mode.
**Prioritization (when queue size > 20)**:
1. Sort by `conviction` (high → medium → low → unset)
2. Within conviction tier, sort by frontmatter `date` descending (most recent thesis first — these are most likely to drive near-term decisions)
3. Take the top 20
4. Surface the dropped tickers in Phase 4's `Notes` block 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 web searches (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` or `web search`). Phase 4's `_catalyst.md` `Notes` column surfaces this — `FMP` entries get a `confirmed via FMP` tag, `web search` entries get `est. via news search`. Lets the user weight the calendar's confidence at a glance.
### 2d. Clean up
```bash
rm -f /tmp/catalyst_earnings.json
```
## 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 `cp` without 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):
```yaml
---
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 |
|---------|-----------|-------|-----------------|-------------|-------|
**`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 Triggers` but 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 Triggers` section at all. Signal: nothing defined can falsify the thesis → the event is unmoored from any pre-registered test. This is the stronger `$prune` + `$lint #60` signal; 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 --event` prompt.** For any earnings (or transcript-bearing) event in the Next 2 Weeks table, tag its Notes cell `→ $transcript TICKER --event ID`. 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 AGENTS.md Rule #9 schema.
1. **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.
2. **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 substring `catalyst 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.
3. **Recent Conviction Changes**: not touched by `$catalyst`.
4. **Portfolio Snapshot**: not touched by `$catalyst`.
**Word cap**: After edits, check total word count. If over 4,000 words (soft cap per `.agents/skills/_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 shell 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.
```bash
# 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 --event ID (date), …]` line whenever any earnings/transcript-bearing event lands in the next 14 days. Omit the line entirely when none do — no noise on quiet fortnights.
If Phase 2b's web search cap activated, surface the degraded-mode warning in the report header: `⚠️ FMP unavailable this run — calendar reflects top-20-by-conviction web search results; [N] tickers carry pre-existing thesis Catalysts data only. Re-run $catalyst when FMP is healthy for full refresh.`