**Feature**: FEAT-420 — FinanceReporter Tier-2 + Narrative Skill **Spec**: `sdd/specs/finance-reporter-tier2-narrative.spec.md` **Status**: pending **Priority**: high **Estimated effort**: S (< 2h) **Depends-on**: TASK-2186 **Assigned-to**: unassigned ---
Scanned 9/1/2026
Install to Claude Code
npx -y skills add phenobarbital/ai-parrot --skill completed --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Completed?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/phenobarbital-completed)More formats (shields.io, HTML) on the badges page.
# TASK-2191: `budget-narrative` composite skill
**Feature**: FEAT-420 — FinanceReporter Tier-2 + Narrative Skill
**Spec**: `sdd/specs/finance-reporter-tier2-narrative.spec.md`
**Status**: pending
**Priority**: high
**Estimated effort**: S (< 2h)
**Depends-on**: TASK-2186
**Assigned-to**: unassigned
---
## Context
Implements **Module 6** of the spec, and is the reason this feature is a *skill*
rather than more Python: criterion **G-C** requires that changing how the report
reads means editing **data**, not code, and shipping no release.
This is a **composite** skill (`{dir}/SKILL.md` + adjacent assets) rather than a
single file, and that is forced rather than stylistic:
`SkillDefinition.MAX_TOKENS = 1000` (`skills/models.py:74`) with a
`field_validator` that **raises** above it (`skills/models.py:76-82`). The facts
contract plus the reference phrasing cannot fit in one body, so they live as
assets served on demand by `read_skill_asset` (`skills/tools.py:491`).
The prose this skill produces is the *only* non-deterministic output in the
feature, so the skill itself carries the discipline that keeps it safe: name
entities and directions, and quote figures only from the facts.
---
## Scope
Create the composite skill directory `.agent/skills/budget-narrative/`:
- **`SKILL.md`** — frontmatter (`name`, `description`, `triggers` — see the hard
requirement below) plus a body, **strictly under 1000 cl100k_base tokens**,
that instructs an LLM to:
- render the `narrative_facts` contract as prose
- read `facts-schema.md` for the contract and `reference.md` for style
- quote **only** figures present in the facts (the mechanical guard in
TASK-2190 enforces this; the skill must make compliance the obvious path)
- state direction and name entities rather than inventing analysis
- produce the sections the two layout profiles bind to (a headline/bottom-line
paragraph, per-division reads, key drivers, a recommendation)
- **`facts-schema.md`** — the `narrative_facts` output contract as TASK-2186
actually shipped it, with each field's meaning and allowed values.
- **`reference.md`** — the phrasing from `executive_summary.py:159-269` as style
exemplars, with the mapping from each fact combination to the sentence shape
the original produced.
- A unit test asserting the skill parses, stays under the cap, and is discovered
as a composite with `assets_dir` populated.
**NOT in scope**:
- Any Python that calls the skill — that is TASK-2192 (`NarrativeMixin`).
- The figure guard implementation (TASK-2190).
- Executable assets. Assets are **documentation only**; a `.py` asset that gets
imported would violate FEAT-324 G1 (see `transformers.py:72-79`).
- Modifying or deleting `.agent/skills/data-storytelling/` — related in theme but
a separate, generic skill. Leave it alone.
---
## Files to Create / Modify
| File | Action | Description |
|---|---|---|
| `.agent/skills/budget-narrative/SKILL.md` | CREATE | Entry point; frontmatter + < 1000-token body |
| `.agent/skills/budget-narrative/facts-schema.md` | CREATE | The `narrative_facts` contract |
| `.agent/skills/budget-narrative/reference.md` | CREATE | Phrasing exemplars ported from the reference artifact |
| `packages/ai-parrot/tests/unit/test_budget_narrative_skill.py` | CREATE | Parse/cap/discovery test |
---
## Codebase Contract (Anti-Hallucination)
### Verified Imports
```python
# For the test only:
from pathlib import Path
from parrot.skills.parsers import parse_skill_directory, parse_skill_file
from parrot.skills.models import SkillDefinition
from parrot.skills.loader import SkillsDirectoryLoader
```
### Existing Signatures to Use
```python
# packages/ai-parrot/src/parrot/skills/parsers.py — THE PARSING RULES
def _count_tokens(text: str) -> int: # line 32
return len(_ENCODING.encode(text)) # line 34
# cl100k_base (real tiktoken), NOT a chars/4 heuristic.
def parse_skill_file(file_path: Path) -> SkillDefinition: # line 37
name: str = metadata.get("name", "") # line 55
description: str = metadata.get("description", "") # line 56
# >>> HARD REQUIREMENT (lines 59-62):
_triggers_sentinel = object()
_raw_triggers = metadata.get("triggers", _triggers_sentinel)
if _raw_triggers is _triggers_sentinel:
raise ValueError(f"Skill file missing 'triggers' field: {file_path}")
# => the `triggers` KEY MUST BE PRESENT. An empty list is allowed
# ("may be an empty list (for composite/directory skills)", line 57),
# but OMITTING the key raises.
if not name: raise ValueError(...) # lines 70-71
if not description: raise ValueError(...) # lines 72-73
template_body = post.content.strip() # line 76 — body AFTER frontmatter
version = str(metadata.get("version", "1.0")) # line 88 (optional)
category = metadata.get("category", None) # line 89 (optional)
priority = int(metadata.get("priority", 90)) # line 90 (optional)
token_count = _count_tokens(template_body) # line 93 <-- body only
return SkillDefinition(...) # lines 95-106
def parse_skill_directory(skill_dir: Path) -> SkillDefinition: # line 109
"""Composite: {dir}/SKILL.md via parse_skill_file, plus assets_dir set."""
```
```python
# packages/ai-parrot/src/parrot/skills/models.py
class SkillDefinition(BaseModel): # line 53
name: str; description: str; triggers: List[str] # lines 59-61
template_body: str # line 66
token_count: int # line 67
file_path: Path # line 68
assets_dir: Optional[Path] = Field(default=None) # line 69 — set for composite only
MAX_TOKENS: ClassVar[int] = 1000 # line 74 <-- HARD CAP
@field_validator("token_count") # line 76
def validate_token_count(cls, v): ... # RAISES above the cap (lines 80-82)
```
```python
# packages/ai-parrot/src/parrot/skills/tools.py — how the LLM reaches this skill
class SkillFileToolkit(AbstractToolkit): # line 371
async def list_skill_commands(self) -> ToolResult: ... # line 413
async def load_skill(self, name: str) -> ToolResult: ... # line 454
# returns the body + an asset manifest
async def read_skill_asset(self, skill_name: str, asset: str) -> ToolResult: ... # line 491
# sandboxed reader; path traversal rejected; "SKILL.md" is RESERVED for load_skill
```
```yaml
# The frontmatter shape to use (verified against the parser above):
---
name: budget-narrative
description: >
Render deterministic budget-variance facts as executive-summary prose.
Quote only figures present in the facts.
triggers: [] # REQUIRED KEY — empty list is valid for composite skills
category: domain
version: "1.0"
---
```
```
# The facts contract to document (from TASK-2186 / spec §2 Data Models):
headline: rev_state (behind|ahead), rev_direction (narrowing|widening|flat),
ebitda_direction (improved|worsened|held_steady),
both_improving | both_worsening | diverging (bools),
first_label, last_label
top_driver: division, project, ebitda_variance, trend (may be null),
urgency (immediate|confirm_trend|check_timing|none) -- or null
division_reads[]: division, kind (on_track|spread|concentrated|offset_by),
named[], offsetter (only for offset_by, else null)
watch[] / bright[]: division, project, ebitda_variance, trend
n_snapshots: int
```
### Does NOT Exist
- ~~`.agent/skills/budget-narrative/`~~ — this task creates it.
- ~~a packaged skill inside `ai-parrot`~~ — zero `.md` skills ship in
`packages/`; skills live as repo data in `.agent/skills/` (20+ present). Do
NOT put this skill under `packages/`.
- ~~`parrot/skills/budget-narrative.md`~~ — `parrot/skills/` is the **code**
package (loader, registry, tools, models), not a skill-data directory.
- ~~`.agent/skills/data-storytelling` as a usable base~~ — it exists but is
generic auto-generated boilerplate about matplotlib/pandas, consumes no facts
contract, and (per the parser rules above) **omits the required `triggers`
key**, so it likely fails to parse and is skipped. Not a template to copy.
- ~~`triggers` being optional~~ — the key is REQUIRED (parsers.py:61-62), even
though its value may be `[]`.
- ~~`SKILL.md` being readable via `read_skill_asset`~~ — it is RESERVED for
`load_skill` (`tools.py:491` docstring). Assets must be *other* filenames.
- ~~a 4-chars-per-token approximation~~ — the cap is measured with real
`cl100k_base` tiktoken on the stripped body.
- ~~frontmatter counting toward `token_count`~~ — only `post.content.strip()`
is counted (parsers.py:76,93).
---
## Implementation Notes
### Pattern to Follow
```markdown
<!-- .agent/skills/budget-narrative/SKILL.md — keep the BODY under 1000 tokens -->
---
name: budget-narrative
description: Render deterministic budget-variance facts as executive-summary prose.
triggers: []
category: domain
version: "1.0"
---
# Budget Variance Narrative
Turn a `narrative_facts` object into a short executive summary.
## Hard rules
1. **Never write a number that is not in the facts.** Every figure is checked
mechanically; one invented figure discards your entire output.
2. State direction and name entities. Do not infer causes the facts do not carry.
3. If a field is null, say what the facts support ("new this period"), never guess.
## What to read
- `facts-schema.md` — every field and its allowed values.
- `reference.md` — the house style, with fact-combination → sentence mappings.
## What to produce
... (four short sections matching the layout binds) ...
```
### Key Constraints
- **Measure the body before committing.** Run the token count and keep clear
headroom (aim ≤ 800) so a later edit does not silently push it over — an
over-cap skill is logged as a warning and **skipped**, so the narrative simply
stops appearing with no error anywhere obvious.
- The `triggers` key must be present. Use `[]` — this skill is invoked
programmatically by the narrator, not by a user typing `/trigger`.
- Assets must be `.md`. No `.py`, no scripts — assets are documentation only (G1).
- Do not name an asset `SKILL.md`; that name is reserved for `load_skill`.
- Document the facts contract from **what TASK-2186 actually shipped**, not from
the spec's draft shape, if the two diverged. The transformer is the authority.
- Write `reference.md` as *exemplars*, explicitly labelled as style guidance, so
an LLM does not treat a sample figure as real data. Use obviously-fake
placeholders (e.g. `$X.XM`) rather than plausible dollar amounts.
### References in Codebase
- `.agent/skills/` — 20+ existing skills; inspect a few for house formatting
- `packages/ai-parrot/src/parrot/skills/parsers.py:37-106` — the parsing rules
- `sdd/artifacts/executive_summary.py:159-269` — the phrasing to port as exemplars
- `packages/ai-parrot/tests/unit/test_skill_definition.py` — test style for skill parsing
---
## Acceptance Criteria
- [ ] `.agent/skills/budget-narrative/SKILL.md` exists with `name`, `description`, and a `triggers` key
- [ ] `parse_skill_file(SKILL.md)` succeeds (no `ValueError`)
- [ ] `token_count` < 1000 with headroom (assert ≤ 900 in the test)
- [ ] `parse_skill_directory(...)` returns a `SkillDefinition` with `assets_dir` set
- [ ] `SkillsDirectoryLoader(paths=[Path(".agent/skills")])` discovers `budget-narrative`
- [ ] `facts-schema.md` documents every field TASK-2186 actually emits
- [ ] `reference.md` uses obviously-fake placeholder figures, never plausible amounts
- [ ] No asset is named `SKILL.md`; no asset is executable (`.py`/`.sh`)
- [ ] The body states the "never write a number not in the facts" rule explicitly
- [ ] `.agent/skills/data-storytelling/` is **unmodified**
- [ ] All tests pass: `pytest packages/ai-parrot/tests/unit/test_budget_narrative_skill.py -v`
---
## Test Specification
```python
# packages/ai-parrot/tests/unit/test_budget_narrative_skill.py (create)
from pathlib import Path
import pytest
from parrot.skills.loader import SkillsDirectoryLoader
from parrot.skills.models import SkillDefinition
from parrot.skills.parsers import parse_skill_directory, parse_skill_file
SKILL_DIR = Path(".agent/skills/budget-narrative")
class TestBudgetNarrativeSkill:
def test_skill_md_parses(self):
definition = parse_skill_file(SKILL_DIR / "SKILL.md")
assert definition.name == "budget-narrative"
assert definition.description
def test_body_under_token_cap_with_headroom(self):
definition = parse_skill_file(SKILL_DIR / "SKILL.md")
assert definition.token_count < SkillDefinition.MAX_TOKENS
assert definition.token_count <= 900, "keep headroom for future edits"
def test_composite_sets_assets_dir(self):
definition = parse_skill_directory(SKILL_DIR)
assert definition.assets_dir == SKILL_DIR
def test_expected_assets_present(self):
names = {p.name for p in SKILL_DIR.iterdir()}
assert {"SKILL.md", "facts-schema.md", "reference.md"} <= names
def test_no_executable_assets(self):
assert not [p for p in SKILL_DIR.iterdir() if p.suffix in {".py", ".sh"}]
def test_body_states_the_no_invented_figures_rule(self):
body = (SKILL_DIR / "SKILL.md").read_text()
assert "not in the facts" in body.lower() or "only figures" in body.lower()
async def test_discovered_by_loader(self):
loader = SkillsDirectoryLoader(paths=[Path(".agent/skills")])
found = await loader.discover()
assert any(d.name == "budget-narrative" for d in found)
```
---
## Agent Instructions
When you pick up this task:
1. **Read the spec** at the path listed above (§7 Known Risks explains the
silent-skip failure mode that makes the token headroom matter)
2. **Check dependencies** — TASK-2186 must be in `sdd/tasks/completed/`; read the
`narrative_facts` implementation it shipped and document **that** contract
3. **Verify the Codebase Contract** — re-read `parsers.py:37-106`; confirm the
`triggers`-key requirement and the tiktoken counting still hold
4. **Update status** in `sdd/tasks/index/finance-reporter-tier2-narrative.json`
→ `"in-progress"` with your session ID
5. **Implement** following the scope, codebase contract, and notes above.
**Measure the token count before committing**, not after.
6. **Verify** all acceptance criteria are met
7. **Move this file** to `sdd/tasks/completed/TASK-2191-budget-narrative-skill.md`
8. **Update index** → `"done"`
9. **Fill in the Completion Note** below
---
## Completion Note
**Completed by**: sdd-worker (Sonnet)
**Date**: 2026-08-07
**Notes**: Created the composite skill at `.agent/skills/budget-narrative/`:
`SKILL.md` (frontmatter with `name`/`description`/`triggers: []`/`category`/
`version`, body covering hard rules — no invented figures, state direction
and name entities, handle nulls honestly, house money/pct formatting — plus
the four sections to produce), `facts-schema.md` (documents the exact
`narrative_facts` contract TASK-2186 shipped, including the `trend_basis`
field which the spec's draft shape didn't list — the transformer is the
authority per the task's own instruction), and `reference.md` (phrasing
exemplars per fact combination, all figures obviously-fake placeholders
like `$X.XM`/`$XX.XK`, never plausible amounts). **`SKILL.md` body
token_count = 676** (well under the 900-headroom target and the 1000 hard
cap). 9 tests pass in `test_budget_narrative_skill.py`.
`.agent/skills/data-storytelling/` untouched (verified by test).
**Deviations from spec**: none functionally, but two environmental notes:
(1) `.agent/` is excluded by this machine's local `.git/info/exclude` (not
the repo's tracked `.gitignore` — many `.agent/skills/*` files are already
tracked) — had to `git add -f` the three new skill files, same pattern
CLAUDE.md documents for `sdd/templates/*.md`. (2) Discovered and fixed a
worktree-testing hazard: importing `parrot.skills.*` transitively imports
`navconfig.conf`, which runs `os.chdir(BASE_DIR)` at import time with
`BASE_DIR` resolving to the MAIN REPO checkout, not this worktree — silently
breaking any test using a repo-relative `Path("...")` for a worktree-local
file. Anchored `SKILL_DIR` in the test to `Path(__file__).resolve().parents[4]`
instead of a bare relative path, and saved a wiki lesson
(`navconfig os.chdir breaks relative paths in worktree tests`) for future
tasks in this feature (TASK-2195/2196 will touch `examples/` fixtures and
should use the same anchoring).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!