Morning Briefing — canonical markdown + fan-out
When to use
Invoke as /catalyst-dev:morning-briefing to produce today's briefing locally and fan it out
to Slack DM, Slack channel, Notion page, and a Loom recording script
([[2026-05-16-catalyst-phase-agent-architecture]] §Initiative 2 Phase 3).
Flags
| Flag | Meaning |
|---|---|
--date YYYY-MM-DD | Target date. Default: today (UTC). |
--dry-run | Write to /tmp/morning-briefing-<date>.md instead of thoughts/briefings/. |
Step 1: Prelude — start session, resolve date
SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT:-plugins/dev}/scripts/morning-briefing"
SESSION_SCRIPT="${CLAUDE_PLUGIN_ROOT:-plugins/dev}/scripts/catalyst-session.sh"
CATALYST_SESSION_ID=$("$SESSION_SCRIPT" start --skill "morning-briefing" \
--ticket "" --workflow "${CATALYST_SESSION_ID:-}")
export CATALYST_SESSION_ID
# Resolve target date + output path. Pass --dry-run / --date through from the user.
OUT_PATH=$(bash "$SCRIPT_DIR/output-path.sh" "$@")
DATE=$(basename "$OUT_PATH" .md | sed 's/^morning-briefing-//')
echo "Target date: $DATE"
echo "Output path: $OUT_PATH"
Step 2: Gather "yesterday" — parallel MCP/CLI queries
Read source: per the
linearisskill's "Reading Linear" section, ticket reads go to the replica via direct SQL. Thegather-linear.shhelper below uses a filteredissues list(an activity window, not a single-ticket read) — the list-shaped case that has no replica form yet, so it stays onlinearis.
Launch the five gather helpers in parallel. Each prints a JSON fragment to its own scratch file;
each degrades silently to {} if its credentials are absent so the briefing always renders.
SCRATCH=$(mktemp -d)
trap 'rm -rf "$SCRATCH"' EXIT
bash "$SCRIPT_DIR/gather-linear.sh" --date "$DATE" > "$SCRATCH/linear.json" &
bash "$SCRIPT_DIR/gather-github.sh" --date "$DATE" > "$SCRATCH/github.json" &
bash "$SCRIPT_DIR/gather-granola.sh" --date "$DATE" > "$SCRATCH/granola.json" &
bash "$SCRIPT_DIR/gather-drive.sh" --date "$DATE" > "$SCRATCH/drive.json" &
bash "$SCRIPT_DIR/gather-calendar.sh" --date "$DATE" > "$SCRATCH/calendar.json" &
wait
If a richer Linear or Notion query is needed beyond what the CLI/REST helpers expose, use the
mcp__linear__* / mcp__notion__* tools directly from this skill — write the result to
$SCRATCH/<source>.json in the same shape ({"<source>": [...]}).
Step 3: Gather "decisions"
Populate the decisions: array from four sources:
- ADR drift —
adr-drift.shreads ADRcode_assertionsfrontmatter and surfaces patterns that drift from the codebase. See ADR-DRIFT.md. - Blocked PRs —
gh search prs --review-requested @me --state open --json …filtered to PRs with no commit in the last 48h. Each becomes one{type: blocked_pr, …}decision. - Judgment-call Linear tickets —
linearis issues list --team <team> --status "Triage,In Progress" --label needs-decision(label name is informational; substitute whatever signal the operator uses). - Pending compound-engineering ADR proposals — the
ticket-compoundcurator queues APPROVE-gated ADR changes atthoughts/shared/compound/pending/<TICKET>.md. Each pending file becomes one decision the morning ritual can approve viabriefing-followup'saction-compound.sh. Emitted astype: judgment_call(the frontmatter schema'stypeenum has nocompound_adrvalue) carrying apending:path — that field is the discriminatorbriefing-followuproutes on.
Synthesize into a decisions.json fragment:
# ADR drift detection (CTL-459)
bash "$SCRIPT_DIR/adr-drift.sh" --root "$(pwd)" > "$SCRATCH/adr-drift.json"
# Blocked-PR + judgment-call sources are still TODO — start with an empty fragment.
echo '{"decisions": []}' > "$SCRATCH/decisions-other.json"
# Pending compound ADR proposals (CTL-789). Resilient to an absent/empty store:
# the glob below simply yields nothing when thoughts/shared/compound/pending/ is missing.
PENDING_DIR="thoughts/shared/compound/pending"
: > "$SCRATCH/compound-pending.jsonl"
if [[ -d "$PENDING_DIR" ]]; then
for pf in "$PENDING_DIR"/*.md; do
[[ -e "$pf" ]] || continue # no-match glob guard (no nullglob needed)
PTICKET=$(grep -m1 '^ticket:' "$pf" 2>/dev/null \
| sed -E 's/^ticket:[[:space:]]*//; s/^"//; s/"$//; s/^'\''//; s/'\''$//')
[[ -z "$PTICKET" ]] && PTICKET="$(basename "$pf" .md)"
PTARGET=$(grep -m1 '^target:' "$pf" 2>/dev/null | sed -E 's/^target:[[:space:]]*//')
PADRID=$(grep -m1 '^adr_id:' "$pf" 2>/dev/null | sed -E 's/^adr_id:[[:space:]]*//')
PRAT=$(grep -m1 '^rationale:' "$pf" 2>/dev/null | sed -E 's/^rationale:[[:space:]]*//')
PSUMMARY="ADR proposal (${PTARGET:-new}${PADRID:+ $PADRID}) from ${PTICKET}${PRAT:+: $PRAT}"
jq -nc \
--arg id "compound-${PTICKET}" \
--arg summary "$PSUMMARY" \
--arg ticket "$PTICKET" \
--arg pending "$pf" \
'{id: $id, type: "judgment_call", summary: $summary, status: "open",
ticket: $ticket, pending: $pending}' >> "$SCRATCH/compound-pending.jsonl"
done
fi
jq -sc '{decisions: .}' "$SCRATCH/compound-pending.jsonl" > "$SCRATCH/compound-pending.json"
# Merge all decision sources into one fragment
jq -s '{decisions: (
((.[0] // {}).decisions // [])
+ ((.[1] // {}).decisions // [])
+ ((.[2] // {}).decisions // []))}' \
"$SCRATCH/adr-drift.json" "$SCRATCH/decisions-other.json" "$SCRATCH/compound-pending.json" \
> "$SCRATCH/decisions.json"
Step 3b: Compound digests — "since last briefing" window (CTL-789)
Two compound-engineering digests the daily review scans: Friction since last briefing (the
primary one — per-phase friction records the daily review wants to skim) and Learnings since
last briefing (new entries in the curated store). Both filter on a since-last-briefing
window: midnight of the most recent prior briefing, or — when there is no prior briefing —
midnight of the day before $DATE. These render as body sections appended after Step 6; they
degrade to a single "none" line when their store is empty or absent.
# ── Resolve the window floor (epoch seconds) ────────────────────────────────
# Most recent thoughts/briefings/YYYY-MM-DD.md strictly older than $DATE.
PREV_BRIEFING_DATE=""
if [[ -d thoughts/briefings ]]; then
for bf in thoughts/briefings/*.md; do
[[ -e "$bf" ]] || continue
bd=$(basename "$bf" .md)
[[ "$bd" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || continue
if [[ "$bd" < "$DATE" ]]; then
[[ -z "$PREV_BRIEFING_DATE" || "$bd" > "$PREV_BRIEFING_DATE" ]] && PREV_BRIEFING_DATE="$bd"
fi
done
fi
# Window floor: prior briefing's midnight, else $DATE minus one day. `date -d`
# (GNU) and `date -j` (BSD/macOS) differ — try both, fall back to "0".
WINDOW_DATE="${PREV_BRIEFING_DATE:-$(date -u -d "$DATE -1 day" +%Y-%m-%d 2>/dev/null \
|| date -j -v-1d -f %Y-%m-%d "$DATE" +%Y-%m-%d 2>/dev/null || echo "$DATE")}"
WINDOW_EPOCH=$(date -u -d "${WINDOW_DATE}T00:00:00Z" +%s 2>/dev/null \
|| date -j -u -f "%Y-%m-%dT%H:%M:%SZ" "${WINDOW_DATE}T00:00:00Z" +%s 2>/dev/null || echo 0)
echo "Compound digest window: since ${WINDOW_DATE} (epoch ${WINDOW_EPOCH})"
# ── Friction digest (PRIMARY) ───────────────────────────────────────────────
# Each record header is the cross-phase contract:
# ## <phase> · <TICKET> · <ISO-8601 timestamp>
# parse by that timestamp, keep records AFTER the window, render newest-first.
: > "$SCRATCH/friction-records.tsv" # ticket \t phase \t iso \t one-line
FRICTION_DIR="thoughts/shared/friction"
if [[ -d "$FRICTION_DIR" ]]; then
for ff in "$FRICTION_DIR"/*.md; do
[[ -e "$ff" ]] || continue
python3 - "$ff" "$WINDOW_EPOCH" >> "$SCRATCH/friction-records.tsv" <<'PY'
import sys, re, datetime
path, floor = sys.argv[1], int(sys.argv[2])
hdr = re.compile(r'^##\s+(?P<phase>[^·]+?)\s+·\s+(?P<ticket>[^·]+?)\s+·\s+(?P<ts>\S+)\s*$')
lines = open(path, encoding='utf-8',