Skip to content
Back to skills

Scan

BSecurity

Cross-project inbox scan — cleans noise, routes signals to projects, creates Todoist tasks for gaps, and ranks priorities by time block. Replaces /triage, /inbox-zero, and /sitrep.

  • 4 stars
  • 0 votes
  • 1 copy
  • 5 views
  • Added September 12, 2026
businesspythongobashsqlapi

Works with

  • api
  • mcp

Security analysis

B88/100
  • criticalSends environment variables or credentials to an external URL

Pro scans all 5 files and shows the line behind each finding

Scanned September 12, 2026

npx -y skills add MattVOLTA/outworkos_diy --skill scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Scan
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/mattvolta-scan/badge)](https://www.skillsdirectory.com/skills/mattvolta-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: scan
description: "Cross-project inbox scan — cleans noise, routes signals to projects, creates Todoist tasks for gaps, and ranks priorities by time block. Replaces /triage, /inbox-zero, and /sitrep."
argument-hint: ["(default: since last scan)" | "1h" | "2h" | "morning" | "afternoon" | "day" | "stale"]
context: fork
---

# Scan — Cross-Project Inbox Sweep

## Runtime Context

At the start of each scan, retrieve user context:

```sql
SELECT email, domain, timezone FROM user_profiles WHERE user_id = auth.uid()
```

Use the returned `email` for sent-mail queries (replacing any hardcoded sender address), `domain` for internal/external classification (e.g., filtering out internal-only emails, excluding your own domain from routing), and `timezone` for date calculations.

Store these values as `USER_EMAIL`, `USER_DOMAIN`, and `USER_TIMEZONE` for use throughout all phases.

## What This Does

One pass over all inboxes (Gmail + Slack DMs). Cleans noise, routes signals to projects, runs a deep scan per active project, creates Todoist tasks for gaps, **auto-closes tasks where follow-up was sent or a reply was received**, and ranks everything by priority. The result is a Todoist view (`@scan`) that always reflects what needs attention right now.

## Data Storage Rules

- **DB is source of truth** via Supabase MCP `execute_sql`. Discover MCP tools via `ToolSearch` at runtime — never hardcode tool names.
- **Project DB IDs** come from the SessionStart manifest (injected into context). Each project entry includes `DB ID`, `Slug`, `Todoist ID`, and `Path`.
- **User ID for all DB writes:** `auth.uid()` — never hardcode a user ID. All queries must use `auth.uid()` for RLS compatibility.
- **Graceful fallback:** If Supabase MCP is unavailable, fall back to file-only I/O. Skills never break from a DB outage.
- **Secrets from Vault:** Use `$OUTWORKOS_ROOT/scripts/get-secret.sh <label>` to retrieve API tokens at runtime.

## Arguments — Time Blocks

The argument controls how many items surface in the final output, not what gets scanned. The scan always processes everything since the last run.

| Argument | Items Surfaced | Output Style |
|----------|---------------|--------------|
| *(default)* | Top 5-7 items | Text summary |
| `1h` | Top 1-2 items | Single focus recommendation |
| `2h` | Top 3-4 items | Short priority list |
| `morning` | Top 5-7 items | Full morning block |
| `afternoon` | Top 5-7 items | Full afternoon block |
| `day` | All ranked items | Complete daily view |
| `stale` | All open tasks across all projects | Backlog maintenance — staleness review + proposals |

**IMPORTANT: Phases 0-5 are invariant.** The time block argument ONLY affects Phase 5d ranking (how many items surface) and Phase 6 output formatting. Never skip inbox cleaning, noise archival, signal archival, or resolved-email archival based on the time block argument. A `1hr` scan does the same cleaning work as a `day` scan — it just surfaces fewer items in the output.

---

## Phase 0: Load Rules + State + Credentials

### 0a. Load Scan Rules from DB

Query all active rules in one call:

```sql
SELECT id, rule_type, rule, context
FROM scan_rules
WHERE user_id = auth.uid()
  AND active = true
  AND (expires_at IS NULL OR expires_at > now())
ORDER BY rule_type, created_at;
```

Parse rules into working sets:
- **Noise filters:** `noise_sender`, `noise_domain`, `noise_subject`, `noise_sender_pattern`, `self_send` rules
- **Routing overrides:** `routing` rules (sender -> project, applied before the cascade)
- **Priority modifiers:** `priority` rules (boost/suppress projects)
- **Behavior instructions:** `behavior` rules (injected as directives)
- **Presentation instructions:** `presentation` rules (applied to output formatting)
- **Contact overrides:** `contact` rules (supplement context map contacts)
- **Thread ignores:** `ignore_thread` rules (skip specific threads)

### 0b. Load Last Scan State

**DB-first:** Query `skill_state` table:
```sql
SELECT state_value FROM skill_state
WHERE skill_name = 'scan' AND state_key = 'last_run';
```

Extract `last_run` timestamp to determine the scan window. If no state exists (first run), default to 7 days.

### 0c. Load Credentials

Run in parallel — read the detailed patterns in `references/api-patterns.md`:
1. **Gmail OAuth** — refresh access token from Vault credentials
2. **Todoist API token** — from Vault
3. **Contacts cache** — refresh if >24h old (see `references/api-patterns.md`)

### 0d. Todoist Sync + Label Reset

Read the detailed script in `references/todoist-patterns.md`:
1. Sync all Todoist data (items, labels, projects, sections) via Sync API
2. **Reset `scan` label** — clear from all tasks so it reflects only this run's output
3. **Create `scan` and `gap` labels** if they don't exist (color: charcoal)
4. Save sync data to `/tmp/scan-sync-data.json`, open items to `/tmp/scan-items.json`, projects to `/tmp/scan-projects.json`, sections to `/tmp/scan-sections.json`

---

## Phase 1: Clean + Scan Inboxes

Run Gmail and Slack scans in parallel. Read detailed patterns in `references/api-patterns.md`.

### 1a. Gmail — Archive Noise + Fetch Signals

Spawn a **Signal Agent** (subagent_type: `general-purpose`, model: `sonnet`). Pass it the Gmail access token, scan window, and noise filter rules from Phase 0a.

> **Quick mode gate:** If `GOOGLE_WORKSPACE_MODE` is `quick`, the Signal Agent runs all phases normally but SKIPS archival in phase 5 (no `batchModify` available). It still counts noise and filtered emails. Set `"archive_skipped": true` in output. The main scan notes: "Noise identified but not archived (quick mode). Switch to full mode to enable auto-archive."

The Signal Agent runs six phases:
1. **Fetch signal emails** — `in:inbox newer_than:Nd -category:promotions -category:social -category:forums -category:updates`. Extract message_id, thread_id, subject, from_name, from_email, date, is_important.
2. **Fetch and archive noise categories** — For each category (promotions, social, forums, updates), search `in:inbox category:{category}` to get message IDs. Collect all IDs for archival. Record counts per category.
3. **Bulk reply check** — `from:{USER_EMAIL} newer_than:Nd` (where `USER_EMAIL` comes from the Runtime Context lookup). Build a Set of replied thread_ids. Mark each signal email as `replied: true/false`.
4. **Apply noise filters** — check each signal email against noise_sender, noise_domain, noise_subject, noise_sender_pattern, self_send rules, and ignore_thread rules. Mark matches as filtered.
5. **Archive noise + filtered** — Gmail batchModify to remove INBOX + UNREAD labels from ALL noise category emails (from step 2) AND filtered signal emails (from step 4). This is not optional.
6. **Verify inbox** — Search `in:inbox` (no filters) and record the remaining count. This count is included in the output for the main scan to verify.

**Output:** `/tmp/scan-signal.json` — noise counts, signal emails with metadata + reply status, filtered emails.

### 1b. Slack DMs — Fetch New Direct Messages

Use Slack MCP tools (discovered via `ToolSearch: "+slack search"` at runtime).

1. Search for DMs since last scan:
   ```
   slack_search_public_and_private(
     query: "to:me",
     channel_types: "im",
     sort: "timestamp",
     sort_dir: "desc",
     after: "{last_scan_unix_timestamp}",
     limit: 20
   )
   ```
   Paginate if needed. Exclude bot messages (`include_bots: false`).

2. For each DM result, extract: sender user_id, sender name, message text (snippet), timestamp, channel_id, message_ts.

3. **Resolve sender identity**: For each unique sender user_id, call `slack_read_user_profile(user_id: "{id}")` to get their email address. This email is used for project routing in Phase 2.

4. For DM threads with multiple messages, use `slack_read_thread` to get full context (limit: 10 messages).

**Output:** `/tmp/scan-slack-dms.json`:
```json
{
  "dms": [
    {
      "source": "slack",
      "sender_name": "Jane Smith",
      "sender_email": "jane@company.com",
      "sender_slack_id": "U0ABC123",
      "channel_id": "D0XYZ789",
      "message_ts": "1234567890.123456",
      "snippet": "Hey, can you review the proposal?",
      "date": "2026-02-28",
      "thread_context": "..."
    }
  ],
  "total_found": 5
}
```

---

## Phase 2: Route Signals to Projects

Match each signal (email or Slack DM) to a project using a 5-tier cascade. **Routing overrides** from `scan_rules` (rule_type: `routing`) are checked first — if a sender has an override, skip the cascade.

### Build Routing Table

For each project in the SessionStart manifest that has a context map:
- **Read local files:** `$OUTWORKOS_PARENT/{ProjectName}/context-map.md` (use manifest project names to construct paths; `OUTWORKOS_PARENT` defaults from the environment)
- **Do NOT** bulk-query `context_map_md` from the DB — the combined result exceeds tool output limits and produces unparseable double-escaped JSON

Extract from each project's context map:
1. **Email addresses** — all emails mentioned, stored as `email -> project_slug`
2. **Organization domains** — derived from emails, excluding generic domains (gmail.com, hotmail.com, outlook.com, yahoo.com) and the user's own domain (`USER_DOMAIN` from Runtime Context)
3. **Subject keywords** — from `subject:"..."` patterns
4. **General keywords** — project names, org names, product/company names
5. **Project purposes** — full description text (for Tier 5 LLM fallback)

### Classification Cascade

Process each signal email and Slack DM through tiers in order, stopping at the first match:

| Tier | Method | Confidence |
|------|--------|------------|
| 0 | `routing` rule override (exact sender match) | Highest |
| 1 | Sender email exact match | High |
| 2 | Sender domain match — **exact emails only** | High |
| 2b | Sender domain match — **unknown sender from known domain** | Needs confirmation |
| 3 | Subject/snippet keyword match | Medium |
| 4 | Body/snippet email or keyword match | Medium |
| 5 | LLM batch fuzzy match (unmatched items) | Low |

**Tier 2b (unknown sender from known domain):** Do NOT auto-route. Mark as `domain_match_unconfirmed` and present to user at end of Phase 2 for confirmation (e.g., `Colin Conrad <colin.conrad@dal.ca> -> AICON (dal.ca) [route / skip / other]`). If confirmed, optionally create a `routing` rule. If skipped, falls through to Tier 3+. This prevents false positives from large domains (`dal.ca`, `rbc.com`, `acoa-apeca.gc.ca`).

**Special rule — Internal Email**: If both sender and recipient share the user's domain (`USER_DOMAIN`), only classify if a project keyword appears in subject/snippet (Tiers 3/4). Otherwise route to a catch-all project.

**Output:** `project_slug -> [signals]` mapping, saved to `/tmp/scan-routed-signals.json`.

---

## Phase 3: Identify Active Projects

A project is "active" (gets a deep-scan worker in Phase 4) if ANY of:

1. **Has new signals** — routed emails or Slack DMs from Phase 2
2. **Has overdue or due-today tasks** — from `/tmp/scan-items.json` (match by Todoist project ID from the manifest)
3. **Has imminent meetings** — Calendar events in the next 48h involving the project's stakeholder

### Calendar Check

Use Google Workspace MCP tools (discovered via `ToolSearch: "+google calendar"` at runtime):
- Fetch events for today + tomorrow
- Match events to projects by attendee email (from context maps) or event title keywords
- Save to `/tmp/scan-calendar-events.json` (including `htmlLink` per event)

### Priority Modifiers

Apply `priority` rules from `scan_rules`:
- `boost` projects get a worker even without signals (if they have any open tasks)
- `suppress: true` projects are skipped entirely (no worker, no output)

**Output:** List of active project slugs with their trigger reasons.

---

## Phase 4: Per-Project Deep Scan (Parallel Workers)

For each active project, spawn a worker agent. **Run all workers in parallel** using the Agent tool.

Read the detailed worker prompt in `references/worker-prompt.md`.

### Worker Agent Settings

| Setting | Value |
|---------|-------|
| `subagent_type` | `general-purpose` |
| `model` | `sonnet` |

### What Each Worker Does

1. **Load project context** — context map from DB or local file. Extract primary stakeholder email, Todoist project ID, person name.

2. **Filter Todoist tasks** — from `/tmp/scan-items.json`, filter to this project's Todoist ID. Categorize as overdue, due today, due tomorrow, upcoming (7 days).

3. **Scan data sources** (parallel within the worker):
   - **Gmail**: Build a scoped query from ALL email addresses in the context map's People section — `(from:{addr1} OR from:{addr2} OR ... OR to:{addr1} OR to:{addr2} OR ...)` last 7 days, fetch content. Do NOT use a broad single-contact query.
   - **Fireflies**: Search transcripts by participant email, last 7 days, pull summaries + action items
   - **Routed signals**: Read this project's signals from `/tmp/scan-routed-signals.json`

4. **Validate against sent mail**: Search `from:{USER_EMAIL} to:{stakeholder_email}` last 7 days. Build cross-reference matrix: for each (task x sent email), determine if action was taken. See `references/worker-prompt.md` for the full matrix protocol.

5. **Identify gaps**: Compare findings against existing Todoist tasks. An item is "untracked" if:
   - An email/DM requests action with no matching Todoist task
   - A meeting action item has no matching task
   - An upcoming event needs prep with no matching task
   - The user has NOT already responded (per sent-mail check)

6. **Return structured JSON**:
```json
{
  "project": "sprint",
  "project_name": "Sprint",
  "todoist_project_id": "6fxfXWp4qPqvX4wm",
  "tasks": {
    "overdue": [...],
    "due_today": [...],
    "due_tomorrow": [...],
    "upcoming": [...]
  },
  "action_taken": ["task_id_1", "task_id_2"],
  "gaps": [
    {
      "content": "Reply to Robert Newcombe — discovery call follow-up",
      "description": "**Why this is here:**\nRobert replied to the Sprint discovery thread on Feb 27. No response yet.\n\n**Sources:**\n- Gmail: \"Re: Discovery call\" — https://mail.google.com/mail/u/0/#inbox/{thread_id}",
      "priority": 3,
      "due_string": "today",
      "source_type": "gmail",
      "source_id": "thread_id_abc"
    }
  ],
  "upcoming_events": [...],
  "signals_summary": "2 new emails (1 thread reply), 1 Slack DM from Jane"
}
```

### Collecting Worker Results

- Wait for all workers to return
- If a worker times out (>90s) or returns invalid JSON: record `{"project": "NAME", "error": "timeout"}` and continue
- Collect all results into `/tmp/scan-worker-results.json`

---

## Phase 5: Create Tasks + Apply Labels + Rank

### 5a. Create Todoist Tasks for Gaps

Read the detailed patterns in `references/todoist-patterns.md`.

For each gap identified across all workers:
1. **Reply check**: If the gap's source_type is `gmail`, verify the thread_id is NOT in the replied thread_ids Set from Phase 1a. If it IS in the set, the user already replied — skip creation. This is a safety net for cases where the worker missed the `replied: true` flag.
2. **Dedup check**: Search existing tasks for matching source_id in description (`Sources:` block contains the same thread_id, transcript_id, or event link). If found, skip creation.
3. **Build task**: Content, description with `**Why this is here:**` and `**Sources:**` blocks, project_id, priority, due date, section_id (Triage section).
4. **Auto-create Triage/Do/Waiting/Defer sections** if the project lacks them.
5. **Apply `gap` label** to all newly created tasks.
6. **Batch all commands** into a single Todoist Sync API call.

### 5b. Apply `scan` Label

After creating gap tasks, apply the `scan` label to every task that should appear in the current scan's Todoist view:
- All tasks due today or overdue (from any active project)
- All tasks due tomorrow (if morning/day block)
- All newly created gap tasks
- All tasks explicitly referenced by workers as needing attention
- Exclude tasks flagged as `action_taken` by workers

Use a single Sync API batch call to apply the label. Read the pattern in `references/todoist-patterns.md`.

### 5c. Archive Resolved Gmail

> **Quick mode gate:** If `GOOGLE_WORKSPACE_MODE` is `quick`, skip Phase 5c archival entirely. Still run the inbox count query via built-in `gmail_search_messages(q: "in:inbox")` for reporting, but do not attempt `batchModify`. Note in output: "N emails identified for archival but left in inbox (quick mode)."

For each signal email that now has a clear disposition, archive it:

| Disposition | Archive? |
|-------------|----------|
| Replied + task exists in Todoist | Yes |
| Replied + no task needed | Yes |
| Unreplied + task just created | Yes |
| Unreplied + task already existed | Yes |
| Not important + unknown sender + no task | Yes |
| Needs human decision (ambiguous) | No — leave in inbox |

Use Gmail batchModify to remove INBOX + UNREAD labels. Process in batches of 1,000 max.

### 5c-verify. Inbox Verification Gate (REQUIRED)

After all archival operations (noise categories, filtered emails, resolved signals), query `in:inbox` with NO filters to get the true remaining inbox count. If count > 0, fetch metadata for all remaining messages.

**For each remaining message, run these checks in order:**

1. **Noise filter check** — apply all `scan_rules` noise filters. If matched -> archive immediately, note the gap in noise rules.
2. **Todoist cross-reference** — load `/tmp/scan-items.json` (all open Todoist tasks). For each remaining email:
   - Search task descriptions for the email's `thread_id` (in `Sources:` blocks or `#inbox/` links)
   - Fuzzy-match the email subject/sender against task `content` fields (same topic = match)
   - If ANY open task tracks this email -> **archive** (it's already in Todoist)
3. **Sent-mail reply check** — for remaining emails not caught by step 2, check if the email's `thread_id` appears in the sent-mail Set built during Phase 1a. If the user has already replied AND the email doesn't require further action -> **archive**.
4. **Classify survivors** — anything still remaining after steps 1-3:
   - **Needs human decision** (ambiguous, unreplied, no task, from a known contact) — leave in inbox, list in "Still in Inbox" output section
   - **Not important + unknown sender** — archive immediately

**This gate must process ALL remaining inbox messages, including those older than the scan window.** Many inbox stragglers are emails that pre-date `newer_than:Nd` from Phase 1a but already have a corresponding Todoist task — step 2 catches these.

**Never report "inbox clear" without running this verification.** The inbox count from this step is the authoritative number shown in the output.

### 5d. Rank Across Projects

Merge all task lists from all workers. Apply the priority stack:

1. **Overdue tasks** — past due date
2. **Meeting prep** — events in next 48h needing preparation
3. **Awaiting response** — someone is waiting on the user
4. **High priority** — Todoist p4/p3 tasks
5. **Deadlines this week** — due in next 7 days
6. **Gap tasks** — newly created from this scan
7. **Everything else** — by creation order (oldest first)

Within each tier:
- Prefer tasks that unblock other people
- Apply `priority` boost/suppress rules from `scan_rules`
- Cross-project tiebreaker: project with an imminent meeting wins

Filter to the time block's item count (see Arguments table).

### 5e. Check Reply Tracking

Check all emails the user is waiting on a reply for. Query the tracking table:

```sql
SELECT id, gmail_thread_id, recipient_name, recipient_emails,
       subject, summary, sent_at, todoist_task_id, project_slug
FROM reply_tracking
WHERE user_id = auth.uid()
  AND status = 'waiting'
ORDER BY sent_at ASC;
```

For each tracked item, check **three channels** for a response (run all checks in parallel):

#### 1. Email Reply Check
Search Gmail for new messages in the thread from the recipient:
```
thread:{thread_id} from:{recipient_email}
```
If any message exists with a date AFTER `sent_at`, mark as reply detected via `email`.

#### 2. Calendar Booking Check
Search Calendar for new events with the recipient as an attendee, created after `sent_at`:
- Use Google Workspace Calendar tools to search events in the next 30 days
- Match by recipient email in attendee list
- If a new event exists that was created after `sent_at`, mark as reply detected via `calendar`

#### 3. Slack Message Check
Search Slack for DMs from the recipient since `sent_at`:
```
slack_search_public_and_private(
  query: "from:{recipient_slack_id_or_name}",
  channel_types: "im",
  after: "{sent_at_unix}",
  limit: 5
)
```
If any DM exists from the recipient after `sent_at`, mark as reply detected via `slack`.

#### Processing Results

**Reply detected:**
1. Update the DB record — set status to `'replied'` immediately:
   ```sql
   UPDATE reply_tracking
   SET status = 'replied',
       reply_detected_at = now(),
       reply_channel = '{channel}',
       reply_summary = '{brief description of the reply}'
   WHERE id = '{tracking_id}';
   ```

2. If `todoist_task_id` exists: **add to the completion batch** (collected in Phase 5f). The linked task will be closed automatically.
3. Add to "Completed — Reply Received" output section with channel and summary.

**No reply detected:**
- Calculate age: `days_waiting = now() - sent_at`
- Add to "Waiting for Reply" output section with age
- If 7+ days: append "(Consider following up)" suggestion

### 5f. Close Completed Tasks (Batch)

Collect all tasks that should be auto-closed based on evidence gathered during this scan:

1. **Action-taken tasks** — all `action_taken` task IDs from worker results (Phase 4). These are tasks where sent mail confirms the user already acted.
2. **Reply-received tasks** — all `todoist_task_id` values from reply tracking items where a reply was detected (Phase 5e).

Deduplicate (a task may appear in both lists). Build a single Todoist Sync API batch:

```python
import json, uuid

completion_ids = set()

# From worker results
for worker in all_worker_results:
    for task_id in worker.get('action_taken', []):
        completion_ids.add(task_id)

# From reply tracking (Phase 5e detections)
for tracked in reply_detections:
    if tracked.get('todoist_task_id'):
        completion_ids.add(tracked['todoist_task_id'])

commands = []
for task_id in completion_ids:
    commands.append({
        'type': 'item_close',
        'uuid': str(uuid.uuid4()),
        'args': {'id': task_id}
    })

if commands:
    payload = {'commands': commands}
    with open('/tmp/scan-complete-batch.json', 'w') as f:
        json.dump(payload, f)
```

Then send:
```bash
TODOIST_API_TOKEN=$("$OUTWORKOS_ROOT/scripts/get-secret.sh" todoist_api_token)
curl -s -X POST "https://api.todoist.com/api/v1/sync" \
  -H "Authorization: Bearer $TODOIST_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d @/tmp/scan-complete-batch.json
```

Report in output: "Completed {N} tasks ({A} follow-up confirmed, {R} reply received)."

**Skip the `scan` label for completed tasks** — they're done, no need to surface them.

---

## Phase 6: Present Results + Capture Feedback

### Output Format

```
## Scan Complete — {date} {time_block}

**Focus:** {top 1 item with 1-sentence rationale}

### Priority List ({count} items)

{Numbered list, each showing:}
1. **[Task name]** — [Project] (due: [date])
   [1-line context: why this, why now]
   [Gmail](link) / [Slack](link) / [Calendar](link)

### New Tasks Created ({count})

{Numbered list of gap tasks with source}

### Completed — Follow-Up Sent ({count})

{Tasks where sent mail confirms the user already acted. Auto-closed this scan.}

1. **{Task name}** — {Project} ~~(was: due {date})~~
   Evidence: Sent "{email subject}" on {date}

### Completed — Reply Received ({count})

{Tracked emails where a reply was detected. Linked Todoist tasks auto-closed, reply_tracking status set to 'replied'.}

1. **{Recipient Name}** — "{Subject}" (sent {N} days ago)
   Reply via {channel}: {brief reply summary}

### Waiting for Reply ({count})

{Emails the user is waiting on a reply for, ordered by age (oldest first).}

1. **{Recipient Name}** — "{brief what was asked}" ({N} days)
2. **{Recipient Name}** — "{brief what was asked}" ({N} days) -- Consider following up

{Items 7+ days old get the "Consider following up" suggestion.}

### Still in Inbox ({count})

{Signal emails/DMs left for human decision — not auto-archived}

### Noise Cleared

{Promotions: N, Social: N, Forums: N, Updates: N, Filtered: N}

---
Any corrections? (e.g., "emails from X are noise", "route Y to project Z")
```

### Reply Tracking Feedback

Completions from Phase 5f are automatic — no confirmation needed. The user can still provide corrections:

**If the user says a completion was wrong** (e.g., "reopen #2", "that wasn't resolved"):
1. Reopen the Todoist task via Sync API (`item_uncomplete`, `args: {id: task_id}`)
2. Reset the reply_tracking record:
   ```sql
   UPDATE reply_tracking
   SET status = 'waiting', reply_detected_at = NULL, reply_channel = NULL, reply_summary = NULL
   WHERE id = '{tracking_id}';
   ```

**If the user wants to dismiss/stop tracking** (e.g., "dismiss #3", "don't need a reply anymore"):
```sql
UPDATE reply_tracking SET status = 'dismissed' WHERE id = '{tracking_id}';
```
Then complete the linked Todoist task (if not already closed).

### Feedback Capture

If the user provides corrections after the scan, parse into `scan_rules` rows and insert:

```sql
INSERT INTO scan_rules (user_id, rule_type, rule, context, source, active)
VALUES (
  auth.uid(),
  '{rule_type}',        -- noise_sender, noise_domain, routing, priority, etc.
  '{rule_value}',       -- e.g., "newsletter@service.com" or "colin.conrad@dal.ca -> aicon"
  '{user_feedback_text}', -- original user words for audit trail
  'user_feedback',
  true
);
```

**Parsing examples:**
- "emails from X are noise" -> `rule_type: 'noise_sender'`, `rule: 'X'`
- "route Y to project Z" -> `rule_type: 'routing'`, `rule: 'Y -> Z'`
- "ignore threads about newsletters" -> `rule_type: 'noise_subject'`, `rule: 'newsletters'`

Confirm: "Rule created: [description]. Will apply on next scan."

### Auto-Detected Rule Proposals

If the scan notices patterns:
- A sender has appeared in 3+ scans and never resulted in a task -> propose `noise_sender` rule
- A sender always routes to the same project -> propose `routing` rule
- A project has had no activity in 30+ days -> propose `priority` suppress rule

Store proposals as `active: false`, `source: 'auto_detected'`. Show in output:
```
### Suggested Rules
- "noreply@service.com" appeared 4 times, never actioned -> add as noise? [y/n]
```

---

## Phase 7: Update State

**DB-first (primary):** Upsert into `skill_state`:
```sql
INSERT INTO skill_state (user_id, skill_name, state_key, state_value)
VALUES (auth.uid(), 'scan', 'last_run', '{
  "last_run": "{ISO-8601 timestamp}",
  "emails_processed": N,
  "slack_dms_processed": N,
  "noise_archived": N,
  "signal_archived": N,
  "tasks_created": N,
  "tasks_completed": N,
  "tasks_completed_action_taken": N,
  "tasks_completed_reply_received": N,
  "scan_label_applied": N,
  "projects_scanned": N
}')
ON CONFLICT (user_id, skill_name, state_key)
DO UPDATE SET state_value = EXCLUDED.state_value, updated_at = now();
```

**Update rule freshness:** For every `scan_rules` row that was actually used during this scan:
```sql
UPDATE scan_rules SET last_applied_at = now()
WHERE id = ANY(ARRAY['{used_rule_ids}']);
```

**Stale rule check:** If any active rules have `last_applied_at` older than 90 days, mention in the output: "N rules haven't matched anything in 90+ days — review with `/scan rules`?"

---

## Compatibility with /whats-next

`/whats-next` remains a standalone single-project deep dive. The two skills share data but don't conflict:

- **Shared cache:** Both use `/tmp/todoist-sync-cache.json` (5-minute TTL). If `/scan` ran recently, `/whats-next` reuses cached Todoist data.
- **No label conflict:** `/whats-next` never touches the `scan` label. It may create tasks with the `gap` label (same dedup logic — check source_id in description before creating).
- **No signal conflict:** `/scan` doesn't write to a `signals` table. Both skills use Todoist as the system of record. Dedup is by source_id in task descriptions.
- **Todoist is the shared state:** Both skills read and write to Todoist. The `scan` label is ephemeral (reset each run). The `gap` label is sticky (stays until user triages).

---

## Edge Cases

| Condition | Behavior |
|-----------|----------|
| No context maps | Noise clearing + Todoist overdue only. Skip workers. |
| Todoist 401/403 | Stop. Direct user to `set-secret.sh todoist_api_token <new>` |
| Gmail OAuth expired | Direct user to `$OUTWORKOS_ROOT/scripts/google-auth.sh` |
| Slack/Fireflies MCP unavailable | Log warning, continue without. Non-fatal. |
| No new signals | Rank existing Todoist tasks only. Don't force-create work. |
| Worker timeout (>90s) | Record error, continue with other workers. Note in output. |
| First run (no state) | 7-day lookback for Gmail, process all inbox for noise clearing. |

---

## Stale Mode (`/scan stale`)

Backlog maintenance mode. Reviews ALL open tasks across ALL context-mapped projects, checks source artifacts for freshness, and proposes priority/due-date/section/completion changes. The user approves before anything is applied.

**Key difference from default mode:** Stale mode skips Phases 1-3 entirely (no inbox scanning). It's about the backlog, not the inbox.

### Phase Flow

| Phase | What Happens | Reused From |
|-------|-------------|-------------|
| Phase 0 | Load rules, state, credentials, Todoist sync + label reset | Reused entirely |
| Phase S1 | Group all open tasks by project | New |
| Phase S2 | Spawn stale workers in parallel | New (same Agent pattern as Phase 4) |
| Phase S3 | Collect proposals, present for approval | New |
| Phase S4 | Apply approved changes via Todoist Sync API batch | New |
| Phase 7 | Update `skill_state` with `stale_last_run` | Adapted |

### Phase S1: Group Tasks by Project

From `/tmp/scan-items.json` (loaded in Phase 0d), group all open tasks by their Todoist project ID. Cross-reference against the SessionStart manifest to get project names, slugs, stakeholder info, and context map paths.

A project gets a stale worker if it has **>= 1 open task** in Todoist.

For each project with tasks:
1. Filter tasks from `/tmp/scan-items.json` by `project_id`
2. Filter sections from `/tmp/scan-sections.json` by `project_id`
3. Read context map from `$OUTWORKOS_PARENT/{ProjectName}/context-map.md` to extract stakeholder name/email
4. Bundle task list, sections, stakeholder info, and today's date for the worker prompt

### Phase S2: Spawn Stale Workers (Parallel)

For each project with open tasks, spawn a stale worker agent. **Run all workers in parallel** using the Agent tool.

Read the detailed worker prompt in `references/stale-worker-prompt.md`.

#### Worker Agent Settings

| Setting | Value |
|---------|-------|
| `subagent_type` | `general-purpose` |
| `model` | `sonnet` |
| `name` | `stale-worker-{project_slug}` |

Each worker:
- Receives ALL open tasks for that project, sections, stakeholder info, today's date
- Categorizes each task by staleness (age, no due date, section stagnation, source link freshness)
- For tasks with Gmail source links: checks thread for new replies or resolution
- Searches sent mail to stakeholder for completion evidence (one batch query per project)
- Checks calendar for upcoming meetings with stakeholder
- Returns JSON with one proposal per task: `complete`, `reprioritize`, `add_due_date`, `move_section`, or `no_change`

#### Collecting Worker Results

- Wait for all workers to return
- If a worker times out (>90s) or returns invalid JSON: record `{"project": "NAME", "error": "timeout"}` and continue
- Collect all results into `/tmp/scan-stale-results.json`

### Phase S3: Present Proposals for Approval

Group proposals by action type and present in tables. **Do not apply any changes until the user approves.**

#### Output Format

```
## Backlog Review — {date}

Reviewed {total_tasks} tasks across {project_count} projects.

### Likely Complete ({count})

Tasks where sent mail or source evidence confirms action was taken.

| # | Task | Project | Evidence | Approve? |
|---|------|---------|----------|----------|
| 1 | Reply to Jane — proposal follow-up | Sprint | Sent "Attached the updated proposal" on Feb 15 | Y |
| 2 | Send contract to Alex | Scale AI | Thread archived, reply sent Feb 20 | Y |

### Needs Attention ({count})

Tasks that should be reprioritized or given a due date based on new activity.

| # | Task | Project | Reason | Proposed Change |
|---|------|---------|--------|-----------------|
| 3 | Prepare board deck | Morrison Park | Board meeting Mar 10, no due date | Add due: Mar 8, priority: High |
| 4 | Review partnership terms | AICON | New reply received Feb 28 | Priority: Normal -> High |

### Consider Deferring ({count})

Tasks 30+ days old with no source activity, no due date, and no stakeholder communication.

| # | Task | Project | Age | Last Activity |
|---|------|---------|-----|---------------|
| 5 | Follow up on partnership | RBCx | 42 days | No sent mail in 35 days |
| 6 | Research competitor pricing | BAI Metrics | 38 days | No source activity |

### No Changes ({count})

{count} tasks across {project_count} projects are fresh and correctly prioritized.

---
Approve all? Or specify by number (e.g., "approve 1,2,3,5" or "skip 4").
```

#### Approval Handling

- **"approve all"** or **"yes"** — apply all proposed changes
- **"approve 1,2,5"** — apply only the numbered proposals
- **"skip 4"** — apply all except the listed numbers
- **"none"** — skip all changes
- Any freeform feedback is treated as corrections (e.g., "don't complete #2, I still need to follow up")

### Phase S4: Apply Approved Changes

Read the batch pattern in `references/todoist-patterns.md` (Phase S4 section).

Build a single Todoist Sync API batch with all approved changes:
- `item_close` for completions
- `item_update` for priority changes and due date additions
- `item_move` for section moves (requires section_id lookup from `/tmp/scan-sections.json`)

Send as one API call. Report results:
```
### Changes Applied

- Completed: {n} tasks
- Reprioritized: {n} tasks
- Due dates added: {n} tasks
- Moved to Defer: {n} tasks
- Errors: {n}
```

### Phase 7 (Stale): Update State

**Separate state key.** `stale_last_run` is independent of `last_run`, so running `/scan stale` doesn't affect the next normal `/scan` window.

```sql
INSERT INTO skill_state (user_id, skill_name, state_key, state_value)
VALUES (auth.uid(), 'scan', 'stale_last_run', '{
  "last_run": "{ISO-8601 timestamp}",
  "tasks_reviewed": N,
  "projects_reviewed": N,
  "proposals_made": N,
  "proposals_approved": N,
  "completed": N,
  "reprioritized": N,
  "due_dates_added": N,
  "deferred": N
}')
ON CONFLICT (user_id, skill_name, state_key)
DO UPDATE SET state_value = EXCLUDED.state_value, updated_at = now();
```

Files in this skill

  • SKILL.md34.5 KB
  • references/api-patterns.md9.3 KB
  • references/stale-worker-prompt.md8.1 KB
  • references/todoist-patterns.md13.9 KB
  • references/worker-prompt.md7.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.