# Ddg Apple Feedback Review

> Invoke ONLY when the user explicitly runs /ddg-apple-feedback-review or names this skill by name. Do NOT auto-invoke from symptom/intent matching. If the user asks about Apple feedback, iOS or macOS issues, user reports, or feedback triage without naming this skill, answer directly instead.

- Skill: `duckduckgo/ddg-apple-feedback-review` (Agent Skill)
- Install (CLI): `npx skillmds@latest add duckduckgo/ddg-apple-feedback-review`
- Raw SKILL.md: https://api.skillmd.com/api/skills/duckduckgo/ddg-apple-feedback-review/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: duckduckgo (https://skillmd.com/u/duckduckgo)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/duckduckgo/ddg-apple-feedback-review

---


# Apple Feedback Review

## Overview

Apple-side feedback is fragmented across five Asana projects (in-app feedback,
Privacy Pro feature requests, Privacy Pro issues, internal product feedback,
and App Store reviews) and split between iOS and macOS. This skill fetches
all relevant sources, deduplicates by task GID, clusters reports into named
issue groups, and presents a per-platform summary with a separate App Store
reviews section. The output is a one-shot snapshot, not a query interface.

**Sensitive data halt:** An organization-level instruction requires you to
stop and surface a `⚠️ SENSITIVE DATA` warning if any task surfaced by this
skill contains legal (SILO, ACP, attorney-client privilege), HR
(performance, compensation, terminations, PIPs), finance (M&A, budget
details), or security (audits, incidents) content. List the triggers and
ask for explicit confirmation before continuing - do not include such tasks
in the report unsolicited.

## When NOT to use

- Alerting or monitoring (this is a snapshot, not a watcher).
- Individual task triage (use Asana directly).
- Anything that needs a feedback database or repeated queries against the
  same window (the skill re-fetches from Asana every time).

## Lethal trifecta note

This skill accesses Asana, an internal data source. A session-level hook
blocks WebFetch and most non-allowlisted MCP tools for the rest of the
session once Asana is touched. If the user needs WebFetch, Slack, or other
non-allowlisted tools afterwards, run this skill in a separate session.

The hook in this environment is also stricter about **Bash**: once Asana
has been touched, Bash calls that look like they're "preparing report
content to post back" are denied, including `python3`, `jq`, and `rm` on
scratch files. This skill is therefore designed to run **without Bash
post-Asana** - all task data stays in tool-call context, the report HTML
is built in-message, and there are no scratch files to clean up.

## Parameters

This skill accepts optional arguments. Parse them from the free-text args
string passed to the skill.

| Parameter | How to detect | Default |
|-----------|--------------|---------|
| **platform** | The literal token `ios` or `macos` (case-insensitive). If absent, run both. | `both` |
| **days** | A number followed by `d`, `days`, or just a bare number (e.g. `14d`, `30 days`, `14`) | `7` |
| **asana_url** | Any token starting with `https://app.asana.com/`. The destination Asana task to write the report into. | *(none - output inline)* |
| **keywords** | Any remaining words after extracting `platform`, `days`, and `asana_url`. Multiple keywords separated by commas, or by spaces if no commas are present. | *(none - no text filter)* |

Extract `asana_url` first (most specific), then `platform`, then `days`, then
treat what remains as keywords.

**Examples:**

| Invocation | platform | days | asana_url | keywords |
|------------|----------|------|-----------|----------|
| `/ddg-apple-feedback-review` | both | 7 | *(none)* | *(none)* |
| `/ddg-apple-feedback-review ios` | ios | 7 | *(none)* | *(none)* |
| `/ddg-apple-feedback-review macos 14d` | macos | 14 | *(none)* | *(none)* |
| `/ddg-apple-feedback-review 30 days vpn` | both | 30 | *(none)* | `vpn` |
| `/ddg-apple-feedback-review ios sync, bookmarks` | ios | 7 | *(none)* | `sync`, `bookmarks` |
| `/ddg-apple-feedback-review macos 14d ai chat, tab bar` | macos | 14 | *(none)* | `ai chat`, `tab bar` |
| `/ddg-apple-feedback-review macos 14d https://app.asana.com/0/123/456` | macos | 14 | `https://app.asana.com/0/123/456` | *(none)* |
| `/ddg-apple-feedback-review ios https://app.asana.com/0/111/222 vpn` | ios | 7 | `https://app.asana.com/0/111/222` | `vpn` |

When keywords contain commas, split on commas and trim each keyword. When there
are no commas, treat each remaining word as a separate keyword. Multi-word
keywords must be comma-separated (e.g. `ai chat, dark mode`).

## Asana projects

Feedback is fragmented across multiple projects on the Apple side. The skill
fetches from each relevant source per platform, then merges and dedupes by
task GID.

### iOS feedback bucket (grouped together by issue)

| Project | GID | Filter |
|---------|-----|--------|
| iOS Feedback | `1206584483643184` | none |
| iOS Privacy Pro Feature Request Feedback | `1207941309648065` | none |
| iOS Privacy Pro Issues | `1207941520938527` | none |
| Internal Product Feedback | `1204912272578138` | `Platform (Internal Feedback)` = "iOS Browser" |

To filter Internal Product Feedback to iOS Browser, pass:

```
custom_fields: {"1204912272636857.value":"1204912272636860"}
```

The `.value` suffix on the field GID is required - without it the Asana API
returns `custom_fields.<gid>: Not a valid search parameter for custom fields`.
`1204912272636857` is the Platform (Internal Feedback) field; `1204912272636860`
is the "iOS Browser" option.

### iOS App Store Reviews bucket (separate section, different schema)

| Project | GID | Filter |
|---------|-----|--------|
| iOS App Store Reviews | `807511686726007` | none |

### macOS feedback bucket (grouped together by issue)

| Project | GID | Filter |
|---------|-----|--------|
| macOS Feedback | `1199178362774117` | none |
| MacOS Privacy Pro Feature Request Feedback | `1207941308245573` | none |
| MacOS Privacy Pro Issues | `1207941519901927` | none |
| Internal Product Feedback | `1204912272578138` | `Platform (Internal Feedback)` = "macOS Browser" |

To filter Internal Product Feedback to macOS Browser, pass:

```
custom_fields: {"1204912272636857.value":"1204912272636859"}
```

(Same `.value` suffix requirement as above.)

### macOS App Store Reviews bucket (separate section, different schema)

| Project | GID | Filter |
|---------|-----|--------|
| macOS App Store Reviews | `1203758364504327` | none |

## Steps

### 1. Parse parameters and calculate the date window

Extract **platform**, **days**, and **keywords** from the args string using the
rules above. Compute the ISO 8601 date for `days` days ago from today. Use this
as the `created_at_after` filter.

Echo the parsed parameters back to the user before fetching:
> Searching feedback from the last **{days}** days for **{platform}**{keywords ? `, filtered by: **{keywords joined}**` : ""}{asana_url ? `. Report destination: {asana_url} (task GID: {extracted_gid})` : ""}.

Including the extracted task GID lets the user sanity-check it before any
write happens. Extract the GID right now using the rules in step 8.1, even
though the write itself is later.

Decide which buckets to fetch:
- `platform = ios`: iOS feedback bucket + iOS App Store Reviews
- `platform = macos`: macOS feedback bucket + macOS App Store Reviews
- `platform = both`: all four buckets

### 2. Fetch recent feedback tasks (with pagination)

For each project in each selected bucket, use `asana_search_tasks`. The API
returns at most 100 results per call, so paginate to collect all tasks within
the time window.

If **keywords** were provided, make one search request **per keyword** using
the `text` parameter, then merge and deduplicate results by task GID. This is
necessary because `asana_search_tasks` accepts only a single text query.

**No scratch directory.** This skill keeps all task data in tool-call context
across steps 2-7 and assembles the final report HTML directly in step 8.5's
`asana_update_task` call. There is no `mktemp` / `$scratch_dir`, no on-disk
JSON dumps, no `jq` pipelines, and no cleanup step. If a step here ever
suggests writing Asana data to disk, treat that as a bug in the skill and
ignore it.

The `opt_fields` below intentionally **excludes `notes`** - the macOS Feedback
project alone returns ~80+ tasks per day and the full notes blob blows past the
tool-result token cap. Fetch `notes` only on a task-by-task basis in step 3
when needed for grouping.

**Use slim `opt_fields` for iOS Feedback and macOS Feedback.** These two
projects carry 30+ custom fields per task (Reporter, Ban Type, Search Area,
SAM Severity values, etc. - most irrelevant to clustering). A 100-task page
with `custom_fields.name,custom_fields.display_value` is ~110 KB and reliably
exceeds the inline tool-result cap. Drop the custom fields from the bulk fetch
on these two projects; cluster from task titles alone; then in step 6 sample
5-10 representative tasks per Top-3 cluster and fetch *those* with full custom
fields via `asana_get_task` for the deep dive. Other projects (Privacy Pro,
Internal Product Feedback, App Store Reviews) carry fewer fields and fit
comfortably with full custom fields at `limit=100`.

| Project | `limit` | `opt_fields` |
|---------|---------|--------------|
| iOS Feedback (`1206584483643184`) | `100` | `name,created_at,completed` |
| macOS Feedback (`1199178362774117`) | `100` | `name,created_at,completed` |
| Privacy Pro / Internal / App Store Reviews | `100` | `name,created_at,completed,custom_fields.name,custom_fields.display_value` |

Tradeoff: the Top-3 deep dive runs against a *sample* (5-10 tasks per cluster)
rather than the full population. Annotate "sampled N of M" in the deep-dive
output so the reader knows. Net win: ~3-4x fewer pagination rounds on the heavy
projects (a 3-day window fits in ~2 pages instead of ~9), and a higher safety
ceiling on long windows.

**Spillover fallback (if a Privacy Pro / Internal / App Store Reviews query
spills).** Uncommon at `limit=100` for those projects, but possible on a
30-day window or after schema bloat. When the MCP server reports a
tool-result spillover for those projects, drop
`custom_fields.name,custom_fields.display_value` from their `opt_fields`
to match the slim-default treatment used for iOS / macOS Feedback, and
sample per-cluster in step 6 the same way. Do **not** attempt to recover
the spilled file via `jq`, `cat`, or any other shell tool - this skill
does not assume Bash is available post-Asana, and Bash will likely be
denied at this point in the session even if it appears to exist. iOS
Feedback / macOS Feedback already use slim `opt_fields` by default, so
this only affects the other projects when they spill.

**First request (per project, per keyword, or once if no keywords):**

```
workspace: "137249556945"
projects_any: <project GID>
created_at_after: <days ago in ISO 8601, e.g. "2026-04-24T00:00:00.000Z">
completed: false
text: <keyword, or omit if no keywords>
custom_fields: <platform filter for Internal Product Feedback only, otherwise omit>
sort_by: "created_at"
sort_ascending: false
limit: 100
opt_fields: <project-specific - see table above>
```

`workspace` is a **required** parameter on `asana_search_tasks`; `137249556945`
is the DDG workspace GID (visible in any DDG Asana URL of the form
`https://app.asana.com/1/137249556945/...`). Omitting it returns a validation
error.

`completed: false` is a **filter** that excludes resolved tasks at the API level
- without it, completed feedback consumes pagination slots against the per-page
`limit` and the 1000-task safety cap, and resolved issues can leak into the
grouped output. The `completed` field also stays in `opt_fields` so the value
is readable on each task as a sanity check.

**Pagination loop (per project, per keyword query):**

After each response, check whether the result count equals the request's
`limit`. If so, there are likely more tasks to fetch. To get the next page,
take the `created_at` timestamp of the **oldest** (last) task in the current
batch and use it as the `created_at_before` filter for the next request,
keeping `created_at_after` unchanged:

```
workspace: "137249556945"
projects_any: <project GID>
created_at_after: <days ago>
created_at_before: <created_at of the last task from the previous batch>
completed: false
text: <keyword, or omit if no keywords>
custom_fields: <platform filter for Internal Product Feedback only, otherwise omit>
sort_by: "created_at"
sort_ascending: false
limit: 100
opt_fields: <same project-specific value as the first request>
```

Repeat until:
- A batch returns fewer than 100 (you have reached the end), **or**
- You have fetched **1000 tasks per project per keyword** (10 pages) as a
  safety cap. macOS Feedback alone can return 400+ tasks in a 5-day window;
  a 14-day or 30-day query may approach this. If the cap is hit, note
  prominently in the output that results were truncated and the deep dive
  may be biased toward recent items.

After all queries for a bucket complete, merge all tasks within the bucket and
deduplicate by task GID. Each bucket is processed independently.

### 3. Read task details where needed

For tasks whose names alone are not descriptive enough to classify, use
`asana_get_task` to read the full description (`notes` or `html_notes` field).
Only do this selectively to avoid excessive API calls - start by grouping on
task names first.

When calling `asana_get_task`, the data protection hook requires tag fields in
`opt_fields`. Use:

```
opt_fields: "name,notes,html_notes,custom_fields,custom_fields.name,custom_fields.display_value,tags,tags.name"
```

### 4. Group by distinct issue (per bucket)

Analyse the task names (and descriptions where read) and cluster them into
distinct issue groups. Use your judgement to identify common themes such as:

- Same error message or symptom described in different words
- Same feature area (e.g. "AI Chat", "VPN", "Sync", "Bookmarks", "Privacy Pro")
- Same user action triggering the problem
- Exact or near-duplicate reports

For each group, choose a short, descriptive label (e.g. "AI Chat fails to
respond", "Privacy Pro VPN disconnects on cellular").

Group the iOS feedback bucket separately from the iOS App Store Reviews bucket
(and same for macOS). The two have different schemas and should be presented
separately.

**Handling high-volume long tails:** A single platform-bucket can return
hundreds of tasks per week. Most cluster into a small number of named groups,
but a long tail of one-off reports (specific website breakage, niche feature
requests, generic praise/dissatisfaction) typically remains. When more than
~40 tasks remain unclustered after named groups are formed:

- Roll them into a single trailing group called **"Miscellaneous / long-tail
  user feedback"** with a one-paragraph theme summary and a sample of links
  (cap the link list at ~50 entries; note "(...remaining N items truncated)"
  if there are more).
- Do not include this group in the Top 3 deep dive.

**Known low-signal clusters to call out as count-only groups:**

- **Quit Time Survey submissions** (macOS) - tasks named "Via First Quit Time
  Survey", "Feedback submitted via quit time survey", or similar. These are
  auto-generated when a user closes the app and answers the survey without
  free-text. They can total ~70+ in a 5-day window. Group them as count-only,
  do not enumerate every link, and do not include in the Top 3 deep dive
  (they have no narrative to deep-dive into).
- **Data Import failures** (macOS) - tasks whose body starts with `Import
  source: <browser/manager>` and `Error: DuckDuckGo_Privacy_Browser.<importer>`
  / `BrowserServicesKit.<importer>`. These are auto-generated by the importer
  on failure. Worth grouping by importer family (Chromium / Firefox / CSV /
  HTML Bookmarks / 1Password) since the failing module is itself the signal.
  Eligible for the Top 3 deep dive when volume warrants it.

### 5. Present the summary

Output is structured per platform. If `platform = both`, render the iOS
sections first, then the macOS sections.

For each bucket, output a section per group with:

- **Heading**: short descriptive label for the issue, with the count in
  parentheses (e.g. `### Privacy Pro VPN disconnects on cellular (8)`)
- **Date range**: earliest and latest creation date in the group
- **Links**: a bullet list of **5-10 representative tasks** by recency (not
  every task in the group). For groups with more than 10 tasks, list the
  most recent 5-10 and note "(...remaining N items, see Asana project view)".
  The full enumeration belongs in Asana's project filter, not the snapshot.
  Format each link as a clickable Asana link using the project GID the task
  came from and the task GID:
  `- [Task name](https://app.asana.com/0/<project_gid>/<task_gid>)`.
  When a task appears in multiple projects of the bucket (rare due to dedup),
  link to the primary project. Do NOT include Asana user names or assignees in
  the task name - anonymise per data protection policy.
- For the count-only noise clusters above (Quit Time Survey, etc.), skip the
  link list entirely and just show the count plus a one-line description.

Sort groups by count descending (most-reported issues first).

After all groups in a bucket, add a brief bucket summary:
- Total feedback items in the window
- Number of distinct issue groups identified
- Top 3 issues by volume

**Don't hand-type GIDs from memory.** A single mistyped digit breaks the link
silently. The GIDs you need are already in the JSON tool-result text returned
by `asana_search_tasks` (step 2) and `asana_get_task` (step 6). When you emit
each `<li><a href="https://app.asana.com/0/<project_gid>/<task_gid>"><name></a></li>`,
transcribe the GID and name verbatim from those tool results - copy the digits,
do not paraphrase or abbreviate. HTML-escape `&`, `<`, `>`, and `"` in the name;
everything else (Unicode, emoji, punctuation, apostrophes) passes through. There
is no scratch-file step here - the tool results are still in your context, so
build the link list directly in the same `asana_update_task` call.

### 6. Top 3 deep dive (per platform feedback bucket)

For the **3 highest-count actionable issue groups** in each platform's
feedback bucket (not the App Store Reviews bucket - see below), perform a
deeper analysis of the tasks' custom field metadata to surface environmental
patterns. **Skip count-only noise clusters** (Quit Time Survey, miscellaneous
long-tail) when picking the top 3 - even if they have the highest counts,
they have no narrative to deep-dive into. Note in the bucket summary if the
top-by-count group was skipped for this reason.

If fewer than 3 actionable groups exist (or fewer remain after skipping
noise clusters), do as many as you have - just one is fine. Drop the section
entirely if there are zero.

**Sample per cluster.** For iOS Feedback / macOS Feedback the bulk fetch in
step 2 used slim `opt_fields`, so custom fields are not on the bucket-level
records. Instead:

1. Pick **5-10 representative tasks** by recency from each Top-3 cluster.
2. Issue all sample fetches in parallel within the same message - they are
   independent. Use `asana_get_task` per task with:
   ```
   opt_fields: "name,notes,custom_fields,custom_fields.name,custom_fields.display_value,tags,tags.name"
   ```
   (`tags` and `tags.name` are required by the data protection hook.)
3. Build the deep-dive table from the sample.
4. Annotate "sampled N of M" in the table footer or Key takeaway, so the
   reader knows the metadata is not full-population.

Bump from 5 to 10 if a cluster looks borderline (mixed signals across
versions, sentiment, etc.). Other-bucket clusters (Privacy Pro, Internal
Product Feedback) carry custom fields on the bulk fetch already - no
sampling needed.

Extract these fields from each sampled task (when present):

- **Version** (app version, e.g. `7.216.0.3` on iOS, `1.187.0` on macOS)
- **OS Version** (e.g. `iOS 26.4.2`, `macOS 15.6.0`)
- **Sentiment** (e.g. `frustrated`, `disappointed`, `confused`)
- **Dealbreaker** (True/False)
- **Source** (where applicable)

Present a breakdown table:

| Factor | Finding |
|--------|---------|
| **OS** | Distribution across major OS versions, specific build numbers |
| **App version** | Which app versions are affected - single regression or multi-version? |
| **Sentiment** | Distribution of sentiment values across the reports |
| **Symptom pattern** | Common recovery steps or trigger actions described across reports |
| **Dealbreaker** | How many users flagged this as a dealbreaker |

After the table, add a **Key takeaway** paragraph summarising:
- Whether the issue is concentrated on a specific OS version or app version
- Whether it appears to be a regression in a single release or a persistent
  issue
- Any suggested investigation direction based on the pattern (e.g. "iOS 26.x
  regression", "Privacy Pro subscription state confusion")

### 7. Top 3 deep dive (per platform App Store Reviews bucket)

App Store Reviews tasks have a different schema (Rating, Version, Store - no
Sentiment, no Dealbreaker, no OS Version). For the **3 highest-count issue
groups** in each platform's App Store Reviews bucket, present a smaller
breakdown. Same fewer-than-3 fallback as step 6: do as many as you have.

| Factor | Finding |
|--------|---------|
| **Rating** | Distribution of star ratings (1-5) across the reports |
| **App version** | Which app versions are affected |
| **Store / locale** | Which country stores the reviews came from (the country flag emoji in the task name is the locale signal) |
| **Symptom pattern** | Common complaints or praise points across reports |

Then a **Key takeaway** paragraph summarising the rating skew and any version
or locale concentration.

### 8. Write report to Asana task (only if `asana_url` was provided)

Skip this step entirely when `asana_url` is empty - the report has already been
rendered inline by steps 5-7 and there is nothing more to do.

When `asana_url` IS provided, the inline render is replaced by a write to the
target task's description. Do not also dump the full report inline - end with a
short confirmation line containing the task link.

#### 8.1 Extract the task GID from the URL

Asana URLs come in two shapes. The task GID is the trailing numeric segment in
the old format, or the segment after `/task/` in the new format:

| URL shape | Task GID |
|-----------|----------|
| `https://app.asana.com/0/<project_gid>/<task_gid>` | last numeric segment |
| `https://app.asana.com/0/<project_gid>/<task_gid>/f` | second-to-last segment |
| `https://app.asana.com/1/<workspace>/project/<project_gid>/task/<task_gid>` | segment after `/task/` |

Strip any trailing `?` query string or `#` fragment before extracting.

#### 8.2 Read the current task description

Fetch the task with `asana_get_task` to inspect its existing description:

```
task_id: <task_gid>
opt_fields: "name,notes,html_notes,tags,tags.name"
```

(`tags` and `tags.name` are required by the data protection hook.)

#### 8.3 Warn before overwriting

If `notes` is non-empty (treat whitespace-only as empty), the task description
is already populated and writing the report will overwrite it. **Stop and ask
the user to confirm before proceeding**, even in auto mode - this is a
visible-to-others write that can destroy data.

Show:
- The task's `name`.
- The first ~500 characters of the existing `notes`, plus `(...truncated, N
  more characters)` if longer.
- An explicit prompt: "Overwrite this with the generated report? (yes/no)"

If the user declines (or anything other than an explicit yes), output the
report inline as if no `asana_url` had been provided and skip the write. Do not
fall back to appending or to creating a comment - those weren't requested.

If `notes` is empty, proceed without prompting.

#### 8.4 Format the report as Asana HTML

**REQUIRED SUB-SKILL:** Use the `asana-formatting:asana-formatting` skill to
convert the markdown report into Asana-compatible HTML. Asana's `html_notes`
field accepts a restricted subset of HTML and rejects standard tags like
`<p>`, `<br>`, `<b>`, `<i>`, `<div>` with a 400 error. The MCP schema lists
`<body>`, `<strong>`, `<em>`, `<u>`, `<s>`, `<code>`, `<ol>`, `<ul>`, `<li>`,
`<a>`, `<blockquote>`, `<pre>`, `<h1>`, `<h2>`, `<hr/>`, `<img>` as the
explicitly accepted set. Let the formatting skill choose how to emit any
given markdown construct (headings, lists, tables, links, paragraph breaks)
- don't hand-craft HTML based on assumptions about what Asana renders.

**Pass the HTML directly as `html_notes` in the same tool call.** Real
newlines in the `html_notes` argument round-trip through the MCP layer to
Asana correctly. Build the HTML in-message and call `asana_update_task`
with the assembled string in one shot.

Do **not**:

- Write the HTML to a file and read it back. The roundtrip is unnecessary
  for any report size that fits in Asana's `html_notes` field, and writing
  Asana data to disk violates the persistence rules in the privacy section.
- Assemble the HTML in a bash heredoc (`cat << EOF` or `cat << 'EOF'`).
  The unquoted form expands `$` and backticks inside the report; the quoted
  form preserves `\n` as the literal two-character escape sequence, which
  Asana renders as visible `\n`. More fundamentally, Bash will likely be
  denied at this point in the session, so heredocs are not a path forward.
- Shell out to `python` or any other interpreter to render the HTML. Same
  Bash-denial reason. Build the HTML in tool-call arguments instead.

**Do not insert blank lines between block-level tags.** Asana already renders
block elements (`<h1>`, `<h2>`, `<ul>`, `<ol>`, `<pre>`, `<blockquote>`) with
built-in vertical separation. Inserting `\n\n` between them - whether
hand-written for source readability or carried over from a pretty-printed
asana-formatting conversion - gets rendered as an extra blank line on top of
that built-in spacing, producing visibly double-spaced output (an empty line
between every heading and its following list, between every list and the
next heading, etc.). Join block tags with no separator (e.g. `</h2><ul>`) or
at most a single real newline. After running the asana-formatting conversion,
scan its output and collapse any `\n\n` (or longer runs of newlines) that
appears immediately before or after a block tag down to a single real newline
or nothing.

`\n\n` between **purely inline content** (plain text, `<strong>`, `<em>`,
`<a>`, `<code>` with no surrounding block tag on either side) is a different
case - it is the only way to produce a paragraph break in `html_notes` since
Asana rejects `<p>`. The block-tag rule above does not apply to those.

**`\n` escape sequences in tool-call XML parameters are literal text, not
newlines.** Claude Code passes parameter content to MCP tools without
processing JSON-style escape sequences. If you type `text.\n<h1>Heading</h1>`
inside the `html_notes` XML parameter, Asana stores those characters
verbatim - the `\n` is two literal characters (backslash + n), and the
HTML view renders them as visible `\n` text before the heading. The fix:

- Between **block tags**, use no separator at all (`</h2><ul>`). Block
  elements have their own vertical spacing in Asana. This is the safest
  default and avoids the escape-sequence trap entirely.
- Where you genuinely need a real newline (paragraph break between inline
  content - the `\n\n` case above), break the parameter content across
  physical lines in the XML. Real line breaks in the parameter source pass
  through as real newlines.
- The asana-formatting skill's documented `\n` / `\n\n` syntax assumes
  JSON-encoded tool calls, where `\n` is processed to a newline. Inside a
  Claude Code `<parameter>...</parameter>` block, that processing does not
  happen, so do not literally type the two-character `\n` sequence and
  expect a newline.

**Asana's plain-text rendering of `<table>` is flat.** The `notes` plain-text
view (and the email/notification preview that derives from it) renders each
table cell on its own line with no separator - so a deep-dive table looks like
`Factor / Finding / OS / Heavily concentrated... / App version / ...` to anyone
reading the preview. The HTML view in the Asana web UI renders the table
correctly, so use tables anyway, but be aware the preview will look ugly. If
preview readability matters more than HTML structure, switch to definition-list
style prose (`<strong>OS:</strong> Heavily concentrated...\n\n<strong>App
version:</strong> ...`) instead of `<table>`.

If the rendered HTML approaches Asana's note-size limit (~64 KB), trim the
long-tail / count-only group link lists first (their cap is already ~50);
preserve the Top 3 deep-dive tables intact.

#### 8.5 Update the task

Call `asana_update_task` with:

```
task_id: <task_gid>
html_notes: <Asana-formatted HTML report>
```

Use `html_notes` (not `notes`) so the structure is preserved.

#### 8.6 Confirm to the user

Output a single line confirming the write, with a clickable link back to the
task. Do NOT also paste the full report inline - the user asked for it to live
in Asana.

### 9. Done

This skill keeps all Asana data in tool-call context only - no scratch
files, no temp directories, nothing on disk. There is no cleanup step.

The MCP server's own tool-result spillover under
`~/.claude/projects/.../tool-results/` is managed by the harness and should
not be touched.

## Privacy and data protection

- Do **not** include user names, emails, assignees, or any PII in the output.
- Use anonymised references ("User A", "a user reported") if quoting task
  descriptions.
- App Store review tasks often embed the reviewer's display name in the task
  title. Treat these as PII and replace with "App Store reviewer" in link
  labels, even though they are public on the App Store.
- Do **not** persist Asana data beyond the session.
  - **Allowed (ephemeral, in-session):** task data held in tool-call context
    while the skill runs, plus the MCP server's own tool-result spillover
    under `~/.claude/projects/<project>/<session>/tool-results/` (managed
    by the harness, not by this skill).
  - **Not allowed:** writing Asana data to disk in any form - no scratch
    directories, no `/tmp/` files, no Write-tool dumps under `.claude/` or
    elsewhere, no `Write` of the rendered report HTML. Also not allowed:
    committing Asana data to the repo, saving it to memory, pasting it
    into other Asana tasks beyond the report destination, or including it
    in any chat output beyond the report itself.
- Web searches and external API calls after accessing Asana are blocked by
  the session-level lethal-trifecta hook, not by this skill. If you hit a
  block, that's the hook doing its job - do not retry. See
  `~/.claude/rules/lethal-trifecta.md` for the policy.
- If any task contains sensitive content (legal, HR, finance, security), halt
  per the org-level sensitive-data instruction described in the overview -
  surface a `⚠️ SENSITIVE DATA` warning, list the triggers, and wait for
  explicit user confirmation before continuing.

## Common mistakes

- **Including `notes` in the bulk-fetch `opt_fields`.** macOS Feedback alone
  returns 80+ tasks/day; the notes blob blows past the tool-result token cap.
  Fetch `notes` only on a per-task basis in step 3.
- **Forgetting the `.value` suffix on custom field GIDs.** Without it the API
  returns `custom_fields.<gid>: Not a valid search parameter for custom fields`.
- **Calling `asana_get_task` without `tags` and `tags.name` in `opt_fields`.**
  The data protection hook requires the tag fields to be requested - without
  them the call is denied.
- **Treating App Store Reviews like the feedback bucket.** Different schema
  (Rating, Store - no Sentiment, no Dealbreaker, no OS Version) and a separate
  deep-dive table (step 7).
- **Picking a count-only noise cluster for the Top 3 deep dive.** Quit Time
  Survey and Miscellaneous long-tail have no narrative; skip them and pick
  the next-highest actionable group.
- **Using the same pagination cursor across keywords or projects.**
  `created_at_before` is set per (project, keyword) query - resetting between
  queries is required, otherwise pages get skipped.
- **Overwriting an existing Asana task description without asking.** Step 8.3
  is mandatory when `notes` is non-empty - even in auto mode. Writes to shared
  tasks affect other people, so warn-then-confirm is not optional.
- **Calling `asana_update_task` with raw markdown or standard HTML.** Asana
  rejects `<p>`, `<br>`, `<b>`, `<i>` and friends with a 400 error. Always
  route through the `asana-formatting:asana-formatting` skill before writing
  `html_notes`.
- **Dumping the full report inline AND writing it to Asana.** When `asana_url`
  is provided, the inline render is replaced by a one-line confirmation. Only
  fall back to inline output if the user declined the overwrite in step 8.3.
- **Trying to render the HTML via `python3` or any other Bash command.** Bash
  calls that look like report-rendering are denied after Asana access in this
  environment. Build the HTML directly in the `asana_update_task` argument -
  see step 8.4.
- **Pretty-printing `html_notes` with blank lines between block tags.** Asana
  treats `\n\n` between block elements like `</h2>` and `<ul>` as an extra
  blank line on top of each block's natural vertical spacing, producing
  visibly double-spaced output. Use no separator or at most a single real
  newline between block tags. `\n\n` between purely inline content (text,
  `<strong>`, `<a>`, etc. with no surrounding block tag) is correct and is
  the only way to get a paragraph break since Asana forbids `<p>`. See step
  8.4.
- **Typing literal `\n` escape sequences inside the `html_notes` XML
  parameter.** Claude Code does not process JSON-style escape sequences in
  tool-call XML parameters - `\n` is two literal characters (backslash + n)
  and Asana renders them as visible `\n` text in the HTML view. Default to
  no separator between block tags (`</h2><ul>`); where you need a real
  newline (paragraph break between inline content), break the parameter
  across physical lines in the XML. See step 8.4.
- **Hand-typing GIDs into the link list HTML from memory.** A single mistyped
  digit gives a broken link that looks correct at a glance. Transcribe each
  GID character-by-character from the `asana_search_tasks` / `asana_get_task`
  tool result that's still in your context - do not retype from memory or
  paraphrase. See step 5.
- **Including `custom_fields` in the bulk-fetch `opt_fields` for iOS Feedback
  or macOS Feedback.** These two projects carry 30+ custom fields per task and
  a 100-task page with full custom fields reliably spills. Use slim `opt_fields`
  (`name,created_at,completed`) for the bulk fetch; sample 5-10 tasks per Top-3
  cluster via `asana_get_task` in step 6 to populate the deep dive. See step 2.
- **Forgetting to annotate "sampled N of M" on the deep-dive output.** With the
  slim-default for the heavy projects, the deep dive runs against a per-cluster
  sample, not the full population. Make the sample size explicit so the reader
  knows the limit.
- **Writing the report HTML to a file (anywhere on disk) before posting.**
  This skill keeps Asana data in tool-call context only. Pass the assembled
  HTML directly as `html_notes` - no `Write` to `/tmp/`, no `Write` under
  `.claude/`, nothing on disk. Real newlines round-trip through MCP fine.
  See step 8.4 and the privacy section.
- **Enumerating every task in a cluster's link list.** Cap at 5-10 representative
  tasks by recency. The full list belongs in Asana's project filter, not the
  snapshot. See step 5.

## What to skip

- Completed tasks (already excluded at the API level by `completed: false` in
  step 2 - listed here as a reminder if the filter is ever dropped)
- Tasks that are clearly not user feedback. Most show up in Internal Product
  Feedback. Skip during grouping when the title matches any of these patterns:
  - Starts with `PR:` (PR-mirror tasks - e.g. `PR: Tab dividers are gone after
    recent update (apple-browsers)`)
  - Starts with `[Action Required]` (administrative checklists - e.g.
    `[Action Required] Update Desktop Browser Feature description if needed`)
  - Internal tracking tasks ("Testing Steps", "How to resolve X breakage?")
  - Milestone tasks (`resource_subtype: milestone`)
- Subtasks (unless they contain distinct feedback - use `is_subtask: false`
  if needed)

